From cf36b1350d3959d256d09d58f945bbec6000175f Mon Sep 17 00:00:00 2001 From: lan Date: Fri, 13 Mar 2026 09:38:18 +0800 Subject: [PATCH] refactor: introduce Wire dependency injection and interface-based architecture - Add Google Wire for compile-time dependency injection - Replace concrete service types with interfaces across handlers - Remove global state (DB, cache) in favor of constructor injection - Split monolithic files into focused modules: - config: separate files for each config domain - dto: converters split by domain (user, post, message, group) - cache: separate metrics.go and redis_cache.go - Introduce unified apperrors package with string-based error codes - Add transaction support with context-aware repository methods - Upgrade golang.org/x/crypto from 0.17.0 to 0.26.0 --- go.mod | 9 +- go.sum | 20 +- internal/cache/cache.go | 862 +------------------ internal/cache/metrics.go | 65 ++ internal/cache/redis_cache.go | 226 +++++ internal/config/cache.go | 70 ++ internal/config/config.go | 263 +----- internal/config/database.go | 39 + internal/config/email.go | 15 + internal/config/external.go | 24 + internal/config/jwt.go | 10 + internal/config/redis.go | 71 ++ internal/config/server.go | 27 + internal/config/storage.go | 50 ++ internal/dto/converter.go | 908 +-------------------- internal/dto/group_converter.go | 96 +++ internal/dto/message_converter.go | 178 ++++ internal/dto/notification_converter.go | 128 +++ internal/dto/post_converter.go | 282 +++++++ internal/dto/user_converter.go | 235 ++++++ internal/errors/app_errors.go | 121 +++ internal/handler/gorse_handler.go | 19 +- internal/handler/group_handler.go | 4 +- internal/handler/message_handler.go | 4 +- internal/handler/post_handler.go | 6 +- internal/handler/system_message_handler.go | 10 +- internal/handler/user_handler.go | 4 +- internal/handler/vote_handler.go | 4 +- internal/model/init.go | 25 +- internal/pkg/response/response.go | 60 +- internal/repository/post_repo.go | 110 +++ internal/repository/transaction.go | 43 + internal/service/chat_service.go | 3 +- internal/service/comment_service.go | 4 +- internal/service/email_code_service.go | 3 - internal/service/group_service.go | 44 +- internal/service/message_service.go | 6 +- internal/service/notification_service.go | 7 +- internal/service/post_service.go | 122 ++- internal/service/schedule_service.go | 9 +- internal/service/sticker_service.go | 10 +- internal/service/system_message_service.go | 3 +- internal/service/upload_service.go | 5 +- internal/service/user_service.go | 161 ++-- internal/wire/handler.go | 74 ++ internal/wire/infrastructure.go | 121 +++ internal/wire/provider_set.go | 11 + internal/wire/repository.go | 29 + internal/wire/service.go | 189 +++++ 49 files changed, 2571 insertions(+), 2218 deletions(-) create mode 100644 internal/cache/metrics.go create mode 100644 internal/cache/redis_cache.go create mode 100644 internal/config/cache.go create mode 100644 internal/config/database.go create mode 100644 internal/config/email.go create mode 100644 internal/config/external.go create mode 100644 internal/config/jwt.go create mode 100644 internal/config/redis.go create mode 100644 internal/config/server.go create mode 100644 internal/config/storage.go create mode 100644 internal/dto/group_converter.go create mode 100644 internal/dto/message_converter.go create mode 100644 internal/dto/notification_converter.go create mode 100644 internal/dto/post_converter.go create mode 100644 internal/dto/user_converter.go create mode 100644 internal/errors/app_errors.go create mode 100644 internal/repository/transaction.go create mode 100644 internal/wire/handler.go create mode 100644 internal/wire/infrastructure.go create mode 100644 internal/wire/provider_set.go create mode 100644 internal/wire/repository.go create mode 100644 internal/wire/service.go diff --git a/go.mod b/go.mod index 1d28a8d..b648e4f 100644 --- a/go.mod +++ b/go.mod @@ -7,17 +7,18 @@ require ( github.com/gin-gonic/gin v1.9.1 github.com/golang-jwt/jwt/v5 v5.2.0 github.com/google/uuid v1.5.0 + github.com/google/wire v0.7.0 github.com/gorse-io/gorse-go v0.5.0-alpha.3 github.com/minio/minio-go/v7 v7.0.66 github.com/redis/go-redis/v9 v9.3.1 github.com/spf13/viper v1.18.2 go.uber.org/zap v1.26.0 - golang.org/x/crypto v0.17.0 + golang.org/x/crypto v0.26.0 golang.org/x/image v0.24.0 + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gorm.io/driver/postgres v1.5.4 gorm.io/driver/sqlite v1.5.4 gorm.io/gorm v1.25.5 - gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df ) require ( @@ -75,8 +76,8 @@ require ( go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/net v0.19.0 // indirect - golang.org/x/sys v0.15.0 // indirect + golang.org/x/net v0.28.0 // indirect + golang.org/x/sys v0.23.0 // indirect golang.org/x/text v0.22.0 // indirect google.golang.org/protobuf v1.31.0 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect diff --git a/go.sum b/go.sum index 573d2b2..e47f2cd 100644 --- a/go.sum +++ b/go.sum @@ -58,13 +58,13 @@ github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= github.com/gorse-io/gorse-go v0.5.0-alpha.3 h1:GR/OWzq016VyFyTTxgQWeayGahRVzB1cGFIW/AaShC4= github.com/gorse-io/gorse-go v0.5.0-alpha.3/go.mod h1:ZxmVHzZPKm5pmEIlqaRDwK0rkfTRHlfziO033XZ+RW0= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -175,20 +175,20 @@ go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= -golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= -golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= +golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 07e1aef..3e88d75 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -3,20 +3,9 @@ package cache import ( "context" "encoding/json" - "fmt" - "log" - "math" "math/rand" - "sort" - "strconv" - "strings" "sync" - "sync/atomic" "time" - - "github.com/redis/go-redis/v9" - - redisPkg "carrot_bbs/internal/pkg/redis" ) // Cache 缓存接口 @@ -71,33 +60,10 @@ type Cache interface { Expire(ctx context.Context, key string, ttl time.Duration) error } -// cacheItem 缓存项(用于内存缓存降级) -type cacheItem struct { - value interface{} - expiration int64 // 过期时间戳(纳秒) -} - const nullMarkerValue = "__carrot_cache_null__" -type cacheMetrics struct { - hit atomic.Int64 - miss atomic.Int64 - decodeError atomic.Int64 - setError atomic.Int64 - invalidate atomic.Int64 -} - -var metrics cacheMetrics var loadLocks sync.Map -type MetricsSnapshot struct { - Hit int64 - Miss int64 - DecodeError int64 - SetError int64 - Invalidate int64 -} - type Settings struct { Enabled bool KeyPrefix string @@ -170,814 +136,6 @@ func normalizePrefix(prefix string) string { return settings.KeyPrefix + ":" + prefix } -func GetMetricsSnapshot() MetricsSnapshot { - return MetricsSnapshot{ - Hit: metrics.hit.Load(), - Miss: metrics.miss.Load(), - DecodeError: metrics.decodeError.Load(), - SetError: metrics.setError.Load(), - Invalidate: metrics.invalidate.Load(), - } -} - -// isExpired 检查是否过期 -func (item *cacheItem) isExpired() bool { - if item.expiration == 0 { - return false - } - return time.Now().UnixNano() > item.expiration -} - -// MemoryCache 内存缓存实现(降级使用) -type MemoryCache struct { - items sync.Map - // cleanupInterval 清理过期缓存的间隔 - cleanupInterval time.Duration - // stopCleanup 停止清理协程的通道 - stopCleanup chan struct{} -} - -// NewMemoryCache 创建内存缓存 -func NewMemoryCache() *MemoryCache { - c := &MemoryCache{ - cleanupInterval: 1 * time.Minute, - stopCleanup: make(chan struct{}), - } - // 启动后台清理协程 - go c.cleanup() - return c -} - -// Set 设置缓存值 -func (c *MemoryCache) Set(key string, value interface{}, ttl time.Duration) { - key = normalizeKey(key) - var expiration int64 - if ttl > 0 { - expiration = time.Now().Add(ttl).UnixNano() - } - c.items.Store(key, &cacheItem{ - value: value, - expiration: expiration, - }) -} - -// Get 获取缓存值 -func (c *MemoryCache) Get(key string) (interface{}, bool) { - key = normalizeKey(key) - val, ok := c.items.Load(key) - if !ok { - return nil, false - } - - item := val.(*cacheItem) - if item.isExpired() { - c.items.Delete(key) - return nil, false - } - - return item.value, true -} - -// Delete 删除缓存 -func (c *MemoryCache) Delete(key string) { - key = normalizeKey(key) - metrics.invalidate.Add(1) - c.items.Delete(key) -} - -// DeleteByPrefix 根据前缀删除缓存 -func (c *MemoryCache) DeleteByPrefix(prefix string) { - prefix = normalizePrefix(prefix) - c.items.Range(func(key, value interface{}) bool { - if keyStr, ok := key.(string); ok { - if strings.HasPrefix(keyStr, prefix) { - metrics.invalidate.Add(1) - c.items.Delete(key) - } - } - return true - }) -} - -// Clear 清空所有缓存 -func (c *MemoryCache) Clear() { - c.items.Range(func(key, value interface{}) bool { - metrics.invalidate.Add(1) - c.items.Delete(key) - return true - }) -} - -// Exists 检查键是否存在 -func (c *MemoryCache) Exists(key string) bool { - _, ok := c.Get(key) - return ok -} - -// Increment 增加计数器的值 -func (c *MemoryCache) Increment(key string) int64 { - return c.IncrementBy(key, 1) -} - -// IncrementBy 增加指定值 -func (c *MemoryCache) IncrementBy(key string, value int64) int64 { - key = normalizeKey(key) - for { - val, ok := c.items.Load(key) - if !ok { - // 键不存在,创建新值 - c.items.Store(key, &cacheItem{ - value: value, - expiration: 0, - }) - return value - } - - item := val.(*cacheItem) - if item.isExpired() { - // 已过期,创建新值 - c.items.Store(key, &cacheItem{ - value: value, - expiration: 0, - }) - return value - } - - // 尝试更新 - currentValue, ok := item.value.(int64) - if !ok { - // 类型不匹配,覆盖为新值 - c.items.Store(key, &cacheItem{ - value: value, - expiration: item.expiration, - }) - return value - } - - newValue := currentValue + value - // 使用 CAS 操作确保并发安全 - if c.items.CompareAndSwap(key, val, &cacheItem{ - value: newValue, - expiration: item.expiration, - }) { - return newValue - } - // CAS 失败,重试 - } -} - -// cleanup 定期清理过期缓存 -func (c *MemoryCache) cleanup() { - ticker := time.NewTicker(c.cleanupInterval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - c.cleanExpired() - case <-c.stopCleanup: - return - } - } -} - -// cleanExpired 清理过期缓存 -func (c *MemoryCache) cleanExpired() { - count := 0 - c.items.Range(func(key, value interface{}) bool { - item := value.(*cacheItem) - if item.isExpired() { - c.items.Delete(key) - count++ - } - return true - }) - if count > 0 { - log.Printf("[Cache] Cleaned %d expired items", count) - } -} - -// Stop 停止缓存清理协程 -func (c *MemoryCache) Stop() { - close(c.stopCleanup) -} - -// ==================== MemoryCache Hash 操作 ==================== - -// hashItem Hash 存储项 -type hashItem struct { - fields sync.Map -} - -// HSet 设置 Hash 字段 -func (c *MemoryCache) HSet(ctx context.Context, key string, field string, value interface{}) error { - key = normalizeKey(key) - item, _ := c.items.Load(key) - var h *hashItem - if item == nil { - h = &hashItem{} - c.items.Store(key, &cacheItem{value: h, expiration: 0}) - } else { - ci := item.(*cacheItem) - if ci.isExpired() { - h = &hashItem{} - c.items.Store(key, &cacheItem{value: h, expiration: 0}) - } else { - h = ci.value.(*hashItem) - } - } - h.fields.Store(field, value) - return nil -} - -// HMSet 批量设置 Hash 字段 -func (c *MemoryCache) HMSet(ctx context.Context, key string, values map[string]interface{}) error { - for field, value := range values { - if err := c.HSet(ctx, key, field, value); err != nil { - return err - } - } - return nil -} - -// HGet 获取 Hash 字段值 -func (c *MemoryCache) HGet(ctx context.Context, key string, field string) (string, error) { - key = normalizeKey(key) - item, ok := c.items.Load(key) - if !ok { - return "", fmt.Errorf("key not found") - } - ci := item.(*cacheItem) - if ci.isExpired() { - c.items.Delete(key) - return "", fmt.Errorf("key not found") - } - h, ok := ci.value.(*hashItem) - if !ok { - return "", fmt.Errorf("key is not a hash") - } - val, ok := h.fields.Load(field) - if !ok { - return "", fmt.Errorf("field not found") - } - switch v := val.(type) { - case string: - return v, nil - case []byte: - return string(v), nil - default: - data, _ := json.Marshal(v) - return string(data), nil - } -} - -// HMGet 批量获取 Hash 字段值 -func (c *MemoryCache) HMGet(ctx context.Context, key string, fields ...string) ([]interface{}, error) { - result := make([]interface{}, len(fields)) - for i, field := range fields { - val, err := c.HGet(ctx, key, field) - if err != nil { - result[i] = nil - } else { - result[i] = val - } - } - return result, nil -} - -// HGetAll 获取 Hash 所有字段 -func (c *MemoryCache) HGetAll(ctx context.Context, key string) (map[string]string, error) { - key = normalizeKey(key) - item, ok := c.items.Load(key) - if !ok { - return nil, fmt.Errorf("key not found") - } - ci := item.(*cacheItem) - if ci.isExpired() { - c.items.Delete(key) - return nil, fmt.Errorf("key not found") - } - h, ok := ci.value.(*hashItem) - if !ok { - return nil, fmt.Errorf("key is not a hash") - } - result := make(map[string]string) - h.fields.Range(func(k, v interface{}) bool { - keyStr := k.(string) - switch val := v.(type) { - case string: - result[keyStr] = val - case []byte: - result[keyStr] = string(val) - default: - data, _ := json.Marshal(val) - result[keyStr] = string(data) - } - return true - }) - return result, nil -} - -// HDel 删除 Hash 字段 -func (c *MemoryCache) HDel(ctx context.Context, key string, fields ...string) error { - key = normalizeKey(key) - item, ok := c.items.Load(key) - if !ok { - return nil - } - ci := item.(*cacheItem) - if ci.isExpired() { - c.items.Delete(key) - return nil - } - h, ok := ci.value.(*hashItem) - if !ok { - return nil - } - for _, field := range fields { - h.fields.Delete(field) - } - return nil -} - -// ==================== MemoryCache Sorted Set 操作 ==================== - -// zItem Sorted Set 成员 -type zItem struct { - score float64 - member string -} - -// zsetItem Sorted Set 存储项 -type zsetItem struct { - members sync.Map // member -> *zItem - byScore *sortedSlice // 按分数排序的切片 -} - -// sortedSlice 简单的排序切片实现 -type sortedSlice struct { - items []*zItem - mu sync.RWMutex -} - -// ZAdd 添加 Sorted Set 成员 -func (c *MemoryCache) ZAdd(ctx context.Context, key string, score float64, member string) error { - key = normalizeKey(key) - item, _ := c.items.Load(key) - var z *zsetItem - if item == nil { - z = &zsetItem{byScore: &sortedSlice{}} - c.items.Store(key, &cacheItem{value: z, expiration: 0}) - } else { - ci := item.(*cacheItem) - if ci.isExpired() { - z = &zsetItem{byScore: &sortedSlice{}} - c.items.Store(key, &cacheItem{value: z, expiration: 0}) - } else { - z = ci.value.(*zsetItem) - } - } - z.members.Store(member, &zItem{score: score, member: member}) - z.byScore.mu.Lock() - // 简单实现:重新构建排序切片 - z.byScore.items = nil - z.members.Range(func(k, v interface{}) bool { - z.byScore.items = append(z.byScore.items, v.(*zItem)) - return true - }) - // 按分数排序 - sort.Slice(z.byScore.items, func(i, j int) bool { - return z.byScore.items[i].score < z.byScore.items[j].score - }) - z.byScore.mu.Unlock() - return nil -} - -// ZRangeByScore 按分数范围获取成员(升序) -func (c *MemoryCache) ZRangeByScore(ctx context.Context, key string, min, max string, offset, count int64) ([]string, error) { - key = normalizeKey(key) - item, ok := c.items.Load(key) - if !ok { - return nil, nil - } - ci := item.(*cacheItem) - if ci.isExpired() { - c.items.Delete(key) - return nil, nil - } - z, ok := ci.value.(*zsetItem) - if !ok { - return nil, fmt.Errorf("key is not a sorted set") - } - - minScore, _ := strconv.ParseFloat(min, 64) - maxScore, _ := strconv.ParseFloat(max, 64) - if min == "-inf" { - minScore = math.Inf(-1) - } - if max == "+inf" { - maxScore = math.Inf(1) - } - - z.byScore.mu.RLock() - defer z.byScore.mu.RUnlock() - - var result []string - var skipped int64 = 0 - for _, item := range z.byScore.items { - if item.score < minScore || item.score > maxScore { - continue - } - if skipped < offset { - skipped++ - continue - } - if count > 0 && int64(len(result)) >= count { - break - } - result = append(result, item.member) - } - return result, nil -} - -// ZRevRangeByScore 按分数范围获取成员(降序) -func (c *MemoryCache) ZRevRangeByScore(ctx context.Context, key string, max, min string, offset, count int64) ([]string, error) { - key = normalizeKey(key) - item, ok := c.items.Load(key) - if !ok { - return nil, nil - } - ci := item.(*cacheItem) - if ci.isExpired() { - c.items.Delete(key) - return nil, nil - } - z, ok := ci.value.(*zsetItem) - if !ok { - return nil, fmt.Errorf("key is not a sorted set") - } - - minScore, _ := strconv.ParseFloat(min, 64) - maxScore, _ := strconv.ParseFloat(max, 64) - if min == "-inf" { - minScore = math.Inf(-1) - } - if max == "+inf" { - maxScore = math.Inf(1) - } - - z.byScore.mu.RLock() - defer z.byScore.mu.RUnlock() - - var result []string - var skipped int64 = 0 - // 从后往前遍历 - for i := len(z.byScore.items) - 1; i >= 0; i-- { - item := z.byScore.items[i] - if item.score < minScore || item.score > maxScore { - continue - } - if skipped < offset { - skipped++ - continue - } - if count > 0 && int64(len(result)) >= count { - break - } - result = append(result, item.member) - } - return result, nil -} - -// ZRem 删除 Sorted Set 成员 -func (c *MemoryCache) ZRem(ctx context.Context, key string, members ...interface{}) error { - key = normalizeKey(key) - item, ok := c.items.Load(key) - if !ok { - return nil - } - ci := item.(*cacheItem) - if ci.isExpired() { - c.items.Delete(key) - return nil - } - z, ok := ci.value.(*zsetItem) - if !ok { - return nil - } - for _, m := range members { - if member, ok := m.(string); ok { - z.members.Delete(member) - } - } - // 重建排序切片 - z.byScore.mu.Lock() - z.byScore.items = nil - z.members.Range(func(k, v interface{}) bool { - z.byScore.items = append(z.byScore.items, v.(*zItem)) - return true - }) - sort.Slice(z.byScore.items, func(i, j int) bool { - return z.byScore.items[i].score < z.byScore.items[j].score - }) - z.byScore.mu.Unlock() - return nil -} - -// ZCard 获取 Sorted Set 成员数量 -func (c *MemoryCache) ZCard(ctx context.Context, key string) (int64, error) { - key = normalizeKey(key) - item, ok := c.items.Load(key) - if !ok { - return 0, nil - } - ci := item.(*cacheItem) - if ci.isExpired() { - c.items.Delete(key) - return 0, nil - } - z, ok := ci.value.(*zsetItem) - if !ok { - return 0, fmt.Errorf("key is not a sorted set") - } - var count int64 = 0 - z.members.Range(func(k, v interface{}) bool { - count++ - return true - }) - return count, nil -} - -// ==================== MemoryCache 计数器操作 ==================== - -// Incr 原子递增(返回新值) -func (c *MemoryCache) Incr(ctx context.Context, key string) (int64, error) { - return c.IncrementBy(key, 1), nil -} - -// Expire 设置过期时间 -func (c *MemoryCache) Expire(ctx context.Context, key string, ttl time.Duration) error { - key = normalizeKey(key) - item, ok := c.items.Load(key) - if !ok { - return fmt.Errorf("key not found") - } - ci := item.(*cacheItem) - var expiration int64 - if ttl > 0 { - expiration = time.Now().Add(ttl).UnixNano() - } - c.items.Store(key, &cacheItem{ - value: ci.value, - expiration: expiration, - }) - return nil -} - -// RedisCache Redis缓存实现 -type RedisCache struct { - client *redisPkg.Client - ctx context.Context -} - -// NewRedisCache 创建Redis缓存 -func NewRedisCache(client *redisPkg.Client) *RedisCache { - return &RedisCache{ - client: client, - ctx: context.Background(), - } -} - -// Set 设置缓存值 -func (c *RedisCache) Set(key string, value interface{}, ttl time.Duration) { - key = normalizeKey(key) - // 将值序列化为JSON - data, err := json.Marshal(value) - if err != nil { - metrics.setError.Add(1) - log.Printf("[RedisCache] Failed to marshal value for key %s: %v", key, err) - return - } - - if err := c.client.Set(c.ctx, key, data, ttl); err != nil { - metrics.setError.Add(1) - log.Printf("[RedisCache] Failed to set key %s: %v", key, err) - } -} - -// Get 获取缓存值 -func (c *RedisCache) Get(key string) (interface{}, bool) { - key = normalizeKey(key) - data, err := c.client.Get(c.ctx, key) - if err != nil { - if err == redis.Nil { - return nil, false - } - log.Printf("[RedisCache] Failed to get key %s: %v", key, err) - return nil, false - } - - // 返回原始字符串,由调用侧决定如何解码为目标类型 - return data, true -} - -// Delete 删除缓存 -func (c *RedisCache) Delete(key string) { - key = normalizeKey(key) - metrics.invalidate.Add(1) - if err := c.client.Del(c.ctx, key); err != nil { - log.Printf("[RedisCache] Failed to delete key %s: %v", key, err) - } -} - -// DeleteByPrefix 根据前缀删除缓存 -func (c *RedisCache) DeleteByPrefix(prefix string) { - prefix = normalizePrefix(prefix) - // 使用原生客户端执行SCAN命令 - rdb := c.client.GetClient() - var cursor uint64 - for { - keys, nextCursor, err := rdb.Scan(c.ctx, cursor, prefix+"*", 100).Result() - if err != nil { - log.Printf("[RedisCache] Failed to scan keys with prefix %s: %v", prefix, err) - return - } - - if len(keys) > 0 { - metrics.invalidate.Add(int64(len(keys))) - if err := c.client.Del(c.ctx, keys...); err != nil { - log.Printf("[RedisCache] Failed to delete keys with prefix %s: %v", prefix, err) - } - } - - cursor = nextCursor - if cursor == 0 { - break - } - } -} - -// Clear 清空所有缓存 -func (c *RedisCache) Clear() { - if settings.DisableFlushDB { - log.Printf("[RedisCache] Skip FlushDB because cache.disable_flushdb=true") - return - } - metrics.invalidate.Add(1) - rdb := c.client.GetClient() - if err := rdb.FlushDB(c.ctx).Err(); err != nil { - log.Printf("[RedisCache] Failed to clear cache: %v", err) - } -} - -// Exists 检查键是否存在 -func (c *RedisCache) Exists(key string) bool { - key = normalizeKey(key) - n, err := c.client.Exists(c.ctx, key) - if err != nil { - log.Printf("[RedisCache] Failed to check existence of key %s: %v", key, err) - return false - } - return n > 0 -} - -// Increment 增加计数器的值 -func (c *RedisCache) Increment(key string) int64 { - return c.IncrementBy(key, 1) -} - -// IncrementBy 增加指定值 -func (c *RedisCache) IncrementBy(key string, value int64) int64 { - key = normalizeKey(key) - rdb := c.client.GetClient() - result, err := rdb.IncrBy(c.ctx, key, value).Result() - if err != nil { - log.Printf("[RedisCache] Failed to increment key %s: %v", key, err) - return 0 - } - return result -} - -// ==================== RedisCache Hash 操作 ==================== - -// HSet 设置 Hash 字段 -func (c *RedisCache) HSet(ctx context.Context, key string, field string, value interface{}) error { - key = normalizeKey(key) - return c.client.HSet(ctx, key, field, value) -} - -// HMSet 批量设置 Hash 字段 -func (c *RedisCache) HMSet(ctx context.Context, key string, values map[string]interface{}) error { - key = normalizeKey(key) - return c.client.HMSet(ctx, key, values) -} - -// HGet 获取 Hash 字段值 -func (c *RedisCache) HGet(ctx context.Context, key string, field string) (string, error) { - key = normalizeKey(key) - return c.client.HGet(ctx, key, field) -} - -// HMGet 批量获取 Hash 字段值 -func (c *RedisCache) HMGet(ctx context.Context, key string, fields ...string) ([]interface{}, error) { - key = normalizeKey(key) - return c.client.HMGet(ctx, key, fields...) -} - -// HGetAll 获取 Hash 所有字段 -func (c *RedisCache) HGetAll(ctx context.Context, key string) (map[string]string, error) { - key = normalizeKey(key) - return c.client.HGetAll(ctx, key) -} - -// HDel 删除 Hash 字段 -func (c *RedisCache) HDel(ctx context.Context, key string, fields ...string) error { - key = normalizeKey(key) - return c.client.HDel(ctx, key, fields...) -} - -// ==================== RedisCache Sorted Set 操作 ==================== - -// ZAdd 添加 Sorted Set 成员 -func (c *RedisCache) ZAdd(ctx context.Context, key string, score float64, member string) error { - key = normalizeKey(key) - return c.client.ZAdd(ctx, key, score, member) -} - -// ZRangeByScore 按分数范围获取成员(升序) -func (c *RedisCache) ZRangeByScore(ctx context.Context, key string, min, max string, offset, count int64) ([]string, error) { - key = normalizeKey(key) - return c.client.ZRangeByScore(ctx, key, min, max, offset, count) -} - -// ZRevRangeByScore 按分数范围获取成员(降序) -func (c *RedisCache) ZRevRangeByScore(ctx context.Context, key string, max, min string, offset, count int64) ([]string, error) { - key = normalizeKey(key) - return c.client.ZRevRangeByScore(ctx, key, max, min, offset, count) -} - -// ZRem 删除 Sorted Set 成员 -func (c *RedisCache) ZRem(ctx context.Context, key string, members ...interface{}) error { - key = normalizeKey(key) - return c.client.ZRem(ctx, key, members...) -} - -// ZCard 获取 Sorted Set 成员数量 -func (c *RedisCache) ZCard(ctx context.Context, key string) (int64, error) { - key = normalizeKey(key) - return c.client.ZCard(ctx, key) -} - -// ==================== RedisCache 计数器操作 ==================== - -// Incr 原子递增(返回新值) -func (c *RedisCache) Incr(ctx context.Context, key string) (int64, error) { - key = normalizeKey(key) - return c.client.Incr(ctx, key) -} - -// Expire 设置过期时间 -func (c *RedisCache) Expire(ctx context.Context, key string, ttl time.Duration) error { - key = normalizeKey(key) - _, err := c.client.Expire(ctx, key, ttl) - return err -} - -// 全局缓存实例 -var globalCache Cache -var once sync.Once - -// InitCache 初始化全局缓存实例(使用Redis) -func InitCache(redisClient *redisPkg.Client) { - once.Do(func() { - if redisClient != nil { - globalCache = NewRedisCache(redisClient) - log.Println("[Cache] Initialized Redis cache") - } else { - globalCache = NewMemoryCache() - log.Println("[Cache] Initialized Memory cache (Redis not available)") - } - }) -} - -// GetCache 获取全局缓存实例 -func GetCache() Cache { - if globalCache == nil { - // 如果未初始化,返回内存缓存作为降级 - log.Println("[Cache] Warning: Cache not initialized, using Memory cache") - return NewMemoryCache() - } - return globalCache -} - -// GetRedisClient 从缓存中获取Redis客户端(仅在Redis模式下有效) -func GetRedisClient() (*redisPkg.Client, error) { - if redisCache, ok := globalCache.(*RedisCache); ok { - return redisCache.client, nil - } - return nil, fmt.Errorf("cache is not using Redis backend") -} - func SetWithJitter(c Cache, key string, value interface{}, ttl time.Duration, jitterRatio float64) { if !settings.Enabled { return @@ -1014,16 +172,16 @@ func GetTyped[T any](c Cache, key string) (T, bool) { } raw, ok := c.Get(key) if !ok { - metrics.miss.Add(1) + recordMiss() return zero, false } if str, ok := raw.(string); ok && str == nullMarkerValue { - metrics.hit.Add(1) + recordHit() return zero, false } if typed, ok := raw.(T); ok { - metrics.hit.Add(1) + recordHit() return typed, true } @@ -1031,29 +189,29 @@ func GetTyped[T any](c Cache, key string) (T, bool) { switch v := raw.(type) { case string: if err := json.Unmarshal([]byte(v), &out); err != nil { - metrics.decodeError.Add(1) + recordDecodeError() return zero, false } - metrics.hit.Add(1) + recordHit() return out, true case []byte: if err := json.Unmarshal(v, &out); err != nil { - metrics.decodeError.Add(1) + recordDecodeError() return zero, false } - metrics.hit.Add(1) + recordHit() return out, true default: data, err := json.Marshal(v) if err != nil { - metrics.decodeError.Add(1) + recordDecodeError() return zero, false } if err := json.Unmarshal(data, &out); err != nil { - metrics.decodeError.Add(1) + recordDecodeError() return zero, false } - metrics.hit.Add(1) + recordHit() return out, true } } diff --git a/internal/cache/metrics.go b/internal/cache/metrics.go new file mode 100644 index 0000000..53cdf74 --- /dev/null +++ b/internal/cache/metrics.go @@ -0,0 +1,65 @@ +package cache + +import "sync/atomic" + +// cacheMetrics 缓存指标 +type cacheMetrics struct { + hit atomic.Int64 + miss atomic.Int64 + decodeError atomic.Int64 + setError atomic.Int64 + invalidate atomic.Int64 +} + +// metrics 全局缓存指标实例 +var metrics cacheMetrics + +// MetricsSnapshot 指标快照 +type MetricsSnapshot struct { + Hit int64 + Miss int64 + DecodeError int64 + SetError int64 + Invalidate int64 +} + +// GetMetricsSnapshot 获取当前指标快照 +func GetMetricsSnapshot() MetricsSnapshot { + return MetricsSnapshot{ + Hit: metrics.hit.Load(), + Miss: metrics.miss.Load(), + DecodeError: metrics.decodeError.Load(), + SetError: metrics.setError.Load(), + Invalidate: metrics.invalidate.Load(), + } +} + +// recordHit 记录缓存命中 +func recordHit() { + metrics.hit.Add(1) +} + +// recordMiss 记录缓存未命中 +func recordMiss() { + metrics.miss.Add(1) +} + +// recordDecodeError 记录解码错误 +func recordDecodeError() { + metrics.decodeError.Add(1) +} + +// recordSetError 记录设置错误 +func recordSetError() { + metrics.setError.Add(1) +} + +// recordInvalidate 记录缓存失效 +func recordInvalidate() { + metrics.invalidate.Add(1) +} + +// recordInvalidateMultiple 记录多个缓存失效 +func recordInvalidateMultiple(count int64) { + metrics.invalidate.Add(count) +} diff --git a/internal/cache/redis_cache.go b/internal/cache/redis_cache.go new file mode 100644 index 0000000..3aded36 --- /dev/null +++ b/internal/cache/redis_cache.go @@ -0,0 +1,226 @@ +package cache + +import ( + "context" + "encoding/json" + "log" + "time" + + "github.com/redis/go-redis/v9" + + redisPkg "carrot_bbs/internal/pkg/redis" +) + +// RedisCache Redis缓存实现 +type RedisCache struct { + client *redisPkg.Client + ctx context.Context +} + +// NewRedisCache 创建Redis缓存 +func NewRedisCache(client *redisPkg.Client) *RedisCache { + return &RedisCache{ + client: client, + ctx: context.Background(), + } +} + +// Set 设置缓存值 +func (c *RedisCache) Set(key string, value interface{}, ttl time.Duration) { + key = normalizeKey(key) + // 将值序列化为JSON + data, err := json.Marshal(value) + if err != nil { + recordSetError() + log.Printf("[RedisCache] Failed to marshal value for key %s: %v", key, err) + return + } + + if err := c.client.Set(c.ctx, key, data, ttl); err != nil { + recordSetError() + log.Printf("[RedisCache] Failed to set key %s: %v", key, err) + } +} + +// Get 获取缓存值 +func (c *RedisCache) Get(key string) (interface{}, bool) { + key = normalizeKey(key) + data, err := c.client.Get(c.ctx, key) + if err != nil { + if err == redis.Nil { + return nil, false + } + log.Printf("[RedisCache] Failed to get key %s: %v", key, err) + return nil, false + } + + // 返回原始字符串,由调用侧决定如何解码为目标类型 + return data, true +} + +// Delete 删除缓存 +func (c *RedisCache) Delete(key string) { + key = normalizeKey(key) + recordInvalidate() + if err := c.client.Del(c.ctx, key); err != nil { + log.Printf("[RedisCache] Failed to delete key %s: %v", key, err) + } +} + +// DeleteByPrefix 根据前缀删除缓存 +func (c *RedisCache) DeleteByPrefix(prefix string) { + prefix = normalizePrefix(prefix) + // 使用原生客户端执行SCAN命令 + rdb := c.client.GetClient() + var cursor uint64 + for { + keys, nextCursor, err := rdb.Scan(c.ctx, cursor, prefix+"*", 100).Result() + if err != nil { + log.Printf("[RedisCache] Failed to scan keys with prefix %s: %v", prefix, err) + return + } + + if len(keys) > 0 { + recordInvalidateMultiple(int64(len(keys))) + if err := c.client.Del(c.ctx, keys...); err != nil { + log.Printf("[RedisCache] Failed to delete keys with prefix %s: %v", prefix, err) + } + } + + cursor = nextCursor + if cursor == 0 { + break + } + } +} + +// Clear 清空所有缓存 +func (c *RedisCache) Clear() { + if settings.DisableFlushDB { + log.Printf("[RedisCache] Skip FlushDB because cache.disable_flushdb=true") + return + } + recordInvalidate() + rdb := c.client.GetClient() + if err := rdb.FlushDB(c.ctx).Err(); err != nil { + log.Printf("[RedisCache] Failed to clear cache: %v", err) + } +} + +// Exists 检查键是否存在 +func (c *RedisCache) Exists(key string) bool { + key = normalizeKey(key) + n, err := c.client.Exists(c.ctx, key) + if err != nil { + log.Printf("[RedisCache] Failed to check existence of key %s: %v", key, err) + return false + } + return n > 0 +} + +// Increment 增加计数器的值 +func (c *RedisCache) Increment(key string) int64 { + return c.IncrementBy(key, 1) +} + +// IncrementBy 增加指定值 +func (c *RedisCache) IncrementBy(key string, value int64) int64 { + key = normalizeKey(key) + rdb := c.client.GetClient() + result, err := rdb.IncrBy(c.ctx, key, value).Result() + if err != nil { + log.Printf("[RedisCache] Failed to increment key %s: %v", key, err) + return 0 + } + return result +} + +// ==================== RedisCache Hash 操作 ==================== + +// HSet 设置 Hash 字段 +func (c *RedisCache) HSet(ctx context.Context, key string, field string, value interface{}) error { + key = normalizeKey(key) + return c.client.HSet(ctx, key, field, value) +} + +// HMSet 批量设置 Hash 字段 +func (c *RedisCache) HMSet(ctx context.Context, key string, values map[string]interface{}) error { + key = normalizeKey(key) + return c.client.HMSet(ctx, key, values) +} + +// HGet 获取 Hash 字段值 +func (c *RedisCache) HGet(ctx context.Context, key string, field string) (string, error) { + key = normalizeKey(key) + return c.client.HGet(ctx, key, field) +} + +// HMGet 批量获取 Hash 字段值 +func (c *RedisCache) HMGet(ctx context.Context, key string, fields ...string) ([]interface{}, error) { + key = normalizeKey(key) + return c.client.HMGet(ctx, key, fields...) +} + +// HGetAll 获取 Hash 所有字段 +func (c *RedisCache) HGetAll(ctx context.Context, key string) (map[string]string, error) { + key = normalizeKey(key) + return c.client.HGetAll(ctx, key) +} + +// HDel 删除 Hash 字段 +func (c *RedisCache) HDel(ctx context.Context, key string, fields ...string) error { + key = normalizeKey(key) + return c.client.HDel(ctx, key, fields...) +} + +// ==================== RedisCache Sorted Set 操作 ==================== + +// ZAdd 添加 Sorted Set 成员 +func (c *RedisCache) ZAdd(ctx context.Context, key string, score float64, member string) error { + key = normalizeKey(key) + return c.client.ZAdd(ctx, key, score, member) +} + +// ZRangeByScore 按分数范围获取成员(升序) +func (c *RedisCache) ZRangeByScore(ctx context.Context, key string, min, max string, offset, count int64) ([]string, error) { + key = normalizeKey(key) + return c.client.ZRangeByScore(ctx, key, min, max, offset, count) +} + +// ZRevRangeByScore 按分数范围获取成员(降序) +func (c *RedisCache) ZRevRangeByScore(ctx context.Context, key string, max, min string, offset, count int64) ([]string, error) { + key = normalizeKey(key) + return c.client.ZRevRangeByScore(ctx, key, max, min, offset, count) +} + +// ZRem 删除 Sorted Set 成员 +func (c *RedisCache) ZRem(ctx context.Context, key string, members ...interface{}) error { + key = normalizeKey(key) + return c.client.ZRem(ctx, key, members...) +} + +// ZCard 获取 Sorted Set 成员数量 +func (c *RedisCache) ZCard(ctx context.Context, key string) (int64, error) { + key = normalizeKey(key) + return c.client.ZCard(ctx, key) +} + +// ==================== RedisCache 计数器操作 ==================== + +// Incr 原子递增(返回新值) +func (c *RedisCache) Incr(ctx context.Context, key string) (int64, error) { + key = normalizeKey(key) + return c.client.Incr(ctx, key) +} + +// Expire 设置过期时间 +func (c *RedisCache) Expire(ctx context.Context, key string, ttl time.Duration) error { + key = normalizeKey(key) + _, err := c.client.Expire(ctx, key, ttl) + return err +} + +// GetRedisClient 获取Redis客户端 +func (c *RedisCache) GetRedisClient() *redisPkg.Client { + return c.client +} diff --git a/internal/config/cache.go b/internal/config/cache.go new file mode 100644 index 0000000..5b4623f --- /dev/null +++ b/internal/config/cache.go @@ -0,0 +1,70 @@ +package config + +import "time" + +// ConversationCacheConfig 会话缓存配置 +type ConversationCacheConfig struct { + // TTL 配置 + DetailTTL string `mapstructure:"detail_ttl"` + ListTTL string `mapstructure:"list_ttl"` + ParticipantTTL string `mapstructure:"participant_ttl"` + UnreadTTL string `mapstructure:"unread_ttl"` + + // 消息缓存配置 + MessageDetailTTL string `mapstructure:"message_detail_ttl"` + MessageListTTL string `mapstructure:"message_list_ttl"` + MessageIndexTTL string `mapstructure:"message_index_ttl"` + MessageCountTTL string `mapstructure:"message_count_ttl"` + + // 批量写入配置 + BatchInterval string `mapstructure:"batch_interval"` + BatchThreshold int `mapstructure:"batch_threshold"` + BatchMaxSize int `mapstructure:"batch_max_size"` + BufferMaxSize int `mapstructure:"buffer_max_size"` +} + +// ConversationCacheSettings 会话缓存运行时配置(用于传递给 cache 包) +type ConversationCacheSettings struct { + DetailTTL time.Duration + ListTTL time.Duration + ParticipantTTL time.Duration + UnreadTTL time.Duration + MessageDetailTTL time.Duration + MessageListTTL time.Duration + MessageIndexTTL time.Duration + MessageCountTTL time.Duration + BatchInterval time.Duration + BatchThreshold int + BatchMaxSize int + BufferMaxSize int +} + +// ToSettings 将 ConversationCacheConfig 转换为 ConversationCacheSettings +func (c *ConversationCacheConfig) ToSettings() *ConversationCacheSettings { + return &ConversationCacheSettings{ + DetailTTL: parseDuration(c.DetailTTL, 5*time.Minute), + ListTTL: parseDuration(c.ListTTL, 60*time.Second), + ParticipantTTL: parseDuration(c.ParticipantTTL, 5*time.Minute), + UnreadTTL: parseDuration(c.UnreadTTL, 30*time.Second), + MessageDetailTTL: parseDuration(c.MessageDetailTTL, 30*time.Minute), + MessageListTTL: parseDuration(c.MessageListTTL, 5*time.Minute), + MessageIndexTTL: parseDuration(c.MessageIndexTTL, 30*time.Minute), + MessageCountTTL: parseDuration(c.MessageCountTTL, 30*time.Minute), + BatchInterval: parseDuration(c.BatchInterval, 5*time.Second), + BatchThreshold: c.BatchThreshold, + BatchMaxSize: c.BatchMaxSize, + BufferMaxSize: c.BufferMaxSize, + } +} + +// parseDuration 解析持续时间字符串,如果解析失败则返回默认值 +func parseDuration(s string, defaultVal time.Duration) time.Duration { + if s == "" { + return defaultVal + } + d, err := time.ParseDuration(s) + if err != nil { + return defaultVal + } + return d +} diff --git a/internal/config/config.go b/internal/config/config.go index b0d3f2f..074bf00 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,19 +1,16 @@ package config import ( - "context" "fmt" "os" "strconv" "strings" "time" - "github.com/minio/minio-go/v7" - "github.com/minio/minio-go/v7/pkg/credentials" - "github.com/redis/go-redis/v9" "github.com/spf13/viper" ) +// Config 主配置结构体 type Config struct { Server ServerConfig `mapstructure:"server"` Database DatabaseConfig `mapstructure:"database"` @@ -30,217 +27,7 @@ type Config struct { ConversationCache ConversationCacheConfig `mapstructure:"conversation_cache"` } -type ServerConfig struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - Mode string `mapstructure:"mode"` -} - -type DatabaseConfig struct { - Type string `mapstructure:"type"` - SQLite SQLiteConfig `mapstructure:"sqlite"` - Postgres PostgresConfig `mapstructure:"postgres"` - MaxIdleConns int `mapstructure:"max_idle_conns"` - MaxOpenConns int `mapstructure:"max_open_conns"` - LogLevel string `mapstructure:"log_level"` - SlowThresholdMs int `mapstructure:"slow_threshold_ms"` - IgnoreRecordNotFound bool `mapstructure:"ignore_record_not_found"` - ParameterizedQueries bool `mapstructure:"parameterized_queries"` -} - -type SQLiteConfig struct { - Path string `mapstructure:"path"` -} - -type PostgresConfig struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - User string `mapstructure:"user"` - Password string `mapstructure:"password"` - DBName string `mapstructure:"dbname"` - SSLMode string `mapstructure:"sslmode"` -} - -func (d PostgresConfig) DSN() string { - return fmt.Sprintf( - "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", - d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode, - ) -} - -type RedisConfig struct { - Type string `mapstructure:"type"` - Redis RedisServerConfig `mapstructure:"redis"` - Miniredis MiniredisConfig `mapstructure:"miniredis"` - PoolSize int `mapstructure:"pool_size"` -} - -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"` -} - -type CacheModuleTTL struct { - PostList int `mapstructure:"post_list_ttl"` - Conversation int `mapstructure:"conversation_ttl"` - UnreadCount int `mapstructure:"unread_count_ttl"` - GroupMembers int `mapstructure:"group_members_ttl"` -} - -type RedisServerConfig struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - Password string `mapstructure:"password"` - DB int `mapstructure:"db"` -} - -func (r RedisServerConfig) Addr() string { - return fmt.Sprintf("%s:%d", r.Host, r.Port) -} - -type MiniredisConfig struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` -} - -type S3Config struct { - Endpoint string `mapstructure:"endpoint"` - AccessKey string `mapstructure:"access_key"` - SecretKey string `mapstructure:"secret_key"` - Bucket string `mapstructure:"bucket"` - UseSSL bool `mapstructure:"use_ssl"` - Region string `mapstructure:"region"` - Domain string `mapstructure:"domain"` // 自定义域名,如 s3.carrot.skin -} - -type JWTConfig struct { - Secret string `mapstructure:"secret"` - AccessTokenExpire time.Duration `mapstructure:"access_token_expire"` - RefreshTokenExpire time.Duration `mapstructure:"refresh_token_expire"` -} - -type LogConfig struct { - Level string `mapstructure:"level"` - Encoding string `mapstructure:"encoding"` - OutputPaths []string `mapstructure:"output_paths"` -} - -type RateLimitConfig struct { - Enabled bool `mapstructure:"enabled"` - RequestsPerMinute int `mapstructure:"requests_per_minute"` -} - -type UploadConfig struct { - MaxFileSize int64 `mapstructure:"max_file_size"` - AllowedTypes []string `mapstructure:"allowed_types"` -} - -type GorseConfig struct { - Address string `mapstructure:"address"` - APIKey string `mapstructure:"api_key"` - Enabled bool `mapstructure:"enabled"` - Dashboard string `mapstructure:"dashboard"` - ImportPassword string `mapstructure:"import_password"` - EmbeddingAPIKey string `mapstructure:"embedding_api_key"` - EmbeddingURL string `mapstructure:"embedding_url"` - EmbeddingModel string `mapstructure:"embedding_model"` -} - -type OpenAIConfig struct { - Enabled bool `mapstructure:"enabled"` - BaseURL string `mapstructure:"base_url"` - APIKey string `mapstructure:"api_key"` - ModerationModel string `mapstructure:"moderation_model"` - ModerationMaxImagesPerRequest int `mapstructure:"moderation_max_images_per_request"` - RequestTimeout int `mapstructure:"request_timeout"` - StrictModeration bool `mapstructure:"strict_moderation"` -} - -type EmailConfig struct { - Enabled bool `mapstructure:"enabled"` - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - Username string `mapstructure:"username"` - Password string `mapstructure:"password"` - FromAddress string `mapstructure:"from_address"` - FromName string `mapstructure:"from_name"` - UseTLS bool `mapstructure:"use_tls"` - InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` - Timeout int `mapstructure:"timeout"` -} - -// ConversationCacheConfig 会话缓存配置 -type ConversationCacheConfig struct { - // TTL 配置 - DetailTTL string `mapstructure:"detail_ttl"` - ListTTL string `mapstructure:"list_ttl"` - ParticipantTTL string `mapstructure:"participant_ttl"` - UnreadTTL string `mapstructure:"unread_ttl"` - - // 消息缓存配置 - MessageDetailTTL string `mapstructure:"message_detail_ttl"` - MessageListTTL string `mapstructure:"message_list_ttl"` - MessageIndexTTL string `mapstructure:"message_index_ttl"` - MessageCountTTL string `mapstructure:"message_count_ttl"` - - // 批量写入配置 - BatchInterval string `mapstructure:"batch_interval"` - BatchThreshold int `mapstructure:"batch_threshold"` - BatchMaxSize int `mapstructure:"batch_max_size"` - BufferMaxSize int `mapstructure:"buffer_max_size"` -} - -// ConversationCacheSettings 会话缓存运行时配置(用于传递给 cache 包) -type ConversationCacheSettings struct { - DetailTTL time.Duration - ListTTL time.Duration - ParticipantTTL time.Duration - UnreadTTL time.Duration - MessageDetailTTL time.Duration - MessageListTTL time.Duration - MessageIndexTTL time.Duration - MessageCountTTL time.Duration - BatchInterval time.Duration - BatchThreshold int - BatchMaxSize int - BufferMaxSize int -} - -// ToSettings 将 ConversationCacheConfig 转换为 ConversationCacheSettings -func (c *ConversationCacheConfig) ToSettings() *ConversationCacheSettings { - return &ConversationCacheSettings{ - DetailTTL: parseDuration(c.DetailTTL, 5*time.Minute), - ListTTL: parseDuration(c.ListTTL, 60*time.Second), - ParticipantTTL: parseDuration(c.ParticipantTTL, 5*time.Minute), - UnreadTTL: parseDuration(c.UnreadTTL, 30*time.Second), - MessageDetailTTL: parseDuration(c.MessageDetailTTL, 30*time.Minute), - MessageListTTL: parseDuration(c.MessageListTTL, 5*time.Minute), - MessageIndexTTL: parseDuration(c.MessageIndexTTL, 30*time.Minute), - MessageCountTTL: parseDuration(c.MessageCountTTL, 30*time.Minute), - BatchInterval: parseDuration(c.BatchInterval, 5*time.Second), - BatchThreshold: c.BatchThreshold, - BatchMaxSize: c.BatchMaxSize, - BufferMaxSize: c.BufferMaxSize, - } -} - -// parseDuration 解析持续时间字符串,如果解析失败则返回默认值 -func parseDuration(s string, defaultVal time.Duration) time.Duration { - if s == "" { - return defaultVal - } - d, err := time.ParseDuration(s) - if err != nil { - return defaultVal - } - return d -} - +// Load 加载配置文件 func Load(configPath string) (*Config, error) { viper.SetConfigFile(configPath) viper.SetConfigType("yaml") @@ -426,49 +213,3 @@ func getEnvOrDefault(key, defaultValue string) string { } return defaultValue } - -// NewRedis 创建Redis客户端(真实Redis) -func NewRedis(cfg *RedisConfig) (*redis.Client, error) { - client := redis.NewClient(&redis.Options{ - Addr: cfg.Redis.Addr(), - Password: cfg.Redis.Password, - DB: cfg.Redis.DB, - PoolSize: cfg.PoolSize, - }) - - ctx := context.Background() - if err := client.Ping(ctx).Err(); err != nil { - return nil, fmt.Errorf("failed to connect to redis: %w", err) - } - - return client, nil -} - -// NewS3 创建S3客户端 -func NewS3(cfg *S3Config) (*minio.Client, error) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) - defer cancel() - - client, err := minio.New(cfg.Endpoint, &minio.Options{ - Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), - Secure: cfg.UseSSL, - }) - if err != nil { - return nil, fmt.Errorf("failed to create S3 client: %w", err) - } - - exists, err := client.BucketExists(ctx, cfg.Bucket) - if err != nil { - return nil, fmt.Errorf("failed to check bucket: %w", err) - } - - if !exists { - if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{ - Region: cfg.Region, - }); err != nil { - return nil, fmt.Errorf("failed to create bucket: %w", err) - } - } - - return client, nil -} diff --git a/internal/config/database.go b/internal/config/database.go new file mode 100644 index 0000000..2dcc14a --- /dev/null +++ b/internal/config/database.go @@ -0,0 +1,39 @@ +package config + +import "fmt" + +// DatabaseConfig 数据库配置 +type DatabaseConfig struct { + Type string `mapstructure:"type"` + SQLite SQLiteConfig `mapstructure:"sqlite"` + Postgres PostgresConfig `mapstructure:"postgres"` + MaxIdleConns int `mapstructure:"max_idle_conns"` + MaxOpenConns int `mapstructure:"max_open_conns"` + LogLevel string `mapstructure:"log_level"` + SlowThresholdMs int `mapstructure:"slow_threshold_ms"` + IgnoreRecordNotFound bool `mapstructure:"ignore_record_not_found"` + ParameterizedQueries bool `mapstructure:"parameterized_queries"` +} + +// SQLiteConfig SQLite 配置 +type SQLiteConfig struct { + Path string `mapstructure:"path"` +} + +// PostgresConfig PostgreSQL 配置 +type PostgresConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + DBName string `mapstructure:"dbname"` + SSLMode string `mapstructure:"sslmode"` +} + +// DSN 返回 PostgreSQL 连接字符串 +func (d PostgresConfig) DSN() string { + return fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode, + ) +} diff --git a/internal/config/email.go b/internal/config/email.go new file mode 100644 index 0000000..4b02770 --- /dev/null +++ b/internal/config/email.go @@ -0,0 +1,15 @@ +package config + +// EmailConfig 邮件配置 +type EmailConfig struct { + Enabled bool `mapstructure:"enabled"` + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + FromAddress string `mapstructure:"from_address"` + FromName string `mapstructure:"from_name"` + UseTLS bool `mapstructure:"use_tls"` + InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` + Timeout int `mapstructure:"timeout"` +} diff --git a/internal/config/external.go b/internal/config/external.go new file mode 100644 index 0000000..8866f6f --- /dev/null +++ b/internal/config/external.go @@ -0,0 +1,24 @@ +package config + +// GorseConfig Gorse 推荐系统配置 +type GorseConfig struct { + Address string `mapstructure:"address"` + APIKey string `mapstructure:"api_key"` + Enabled bool `mapstructure:"enabled"` + Dashboard string `mapstructure:"dashboard"` + ImportPassword string `mapstructure:"import_password"` + EmbeddingAPIKey string `mapstructure:"embedding_api_key"` + EmbeddingURL string `mapstructure:"embedding_url"` + EmbeddingModel string `mapstructure:"embedding_model"` +} + +// OpenAIConfig OpenAI 配置 +type OpenAIConfig struct { + Enabled bool `mapstructure:"enabled"` + BaseURL string `mapstructure:"base_url"` + APIKey string `mapstructure:"api_key"` + ModerationModel string `mapstructure:"moderation_model"` + ModerationMaxImagesPerRequest int `mapstructure:"moderation_max_images_per_request"` + RequestTimeout int `mapstructure:"request_timeout"` + StrictModeration bool `mapstructure:"strict_moderation"` +} diff --git a/internal/config/jwt.go b/internal/config/jwt.go new file mode 100644 index 0000000..0a0ab28 --- /dev/null +++ b/internal/config/jwt.go @@ -0,0 +1,10 @@ +package config + +import "time" + +// JWTConfig JWT 配置 +type JWTConfig struct { + Secret string `mapstructure:"secret"` + AccessTokenExpire time.Duration `mapstructure:"access_token_expire"` + RefreshTokenExpire time.Duration `mapstructure:"refresh_token_expire"` +} diff --git a/internal/config/redis.go b/internal/config/redis.go new file mode 100644 index 0000000..9ea540a --- /dev/null +++ b/internal/config/redis.go @@ -0,0 +1,71 @@ +package config + +import ( + "context" + "fmt" + + "github.com/redis/go-redis/v9" +) + +// RedisConfig Redis 配置 +type RedisConfig struct { + Type string `mapstructure:"type"` + Redis RedisServerConfig `mapstructure:"redis"` + Miniredis MiniredisConfig `mapstructure:"miniredis"` + PoolSize int `mapstructure:"pool_size"` +} + +// 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"` +} + +// CacheModuleTTL 缓存模块 TTL 配置 +type CacheModuleTTL struct { + PostList int `mapstructure:"post_list_ttl"` + Conversation int `mapstructure:"conversation_ttl"` + UnreadCount int `mapstructure:"unread_count_ttl"` + GroupMembers int `mapstructure:"group_members_ttl"` +} + +// RedisServerConfig Redis 服务器配置 +type RedisServerConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Password string `mapstructure:"password"` + DB int `mapstructure:"db"` +} + +// Addr 返回 Redis 服务器地址 +func (r RedisServerConfig) Addr() string { + return fmt.Sprintf("%s:%d", r.Host, r.Port) +} + +// MiniredisConfig Miniredis 配置(用于测试) +type MiniredisConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` +} + +// NewRedis 创建 Redis 客户端(真实 Redis) +func NewRedis(cfg *RedisConfig) (*redis.Client, error) { + client := redis.NewClient(&redis.Options{ + Addr: cfg.Redis.Addr(), + Password: cfg.Redis.Password, + DB: cfg.Redis.DB, + PoolSize: cfg.PoolSize, + }) + + ctx := context.Background() + if err := client.Ping(ctx).Err(); err != nil { + return nil, fmt.Errorf("failed to connect to redis: %w", err) + } + + return client, nil +} diff --git a/internal/config/server.go b/internal/config/server.go new file mode 100644 index 0000000..ae7794a --- /dev/null +++ b/internal/config/server.go @@ -0,0 +1,27 @@ +package config + +// ServerConfig 服务器配置 +type ServerConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Mode string `mapstructure:"mode"` +} + +// LogConfig 日志配置 +type LogConfig struct { + Level string `mapstructure:"level"` + Encoding string `mapstructure:"encoding"` + OutputPaths []string `mapstructure:"output_paths"` +} + +// RateLimitConfig 限流配置 +type RateLimitConfig struct { + Enabled bool `mapstructure:"enabled"` + RequestsPerMinute int `mapstructure:"requests_per_minute"` +} + +// UploadConfig 上传配置 +type UploadConfig struct { + MaxFileSize int64 `mapstructure:"max_file_size"` + AllowedTypes []string `mapstructure:"allowed_types"` +} diff --git a/internal/config/storage.go b/internal/config/storage.go new file mode 100644 index 0000000..4c02b92 --- /dev/null +++ b/internal/config/storage.go @@ -0,0 +1,50 @@ +package config + +import ( + "context" + "fmt" + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +// S3Config S3 存储配置 +type S3Config struct { + Endpoint string `mapstructure:"endpoint"` + AccessKey string `mapstructure:"access_key"` + SecretKey string `mapstructure:"secret_key"` + Bucket string `mapstructure:"bucket"` + UseSSL bool `mapstructure:"use_ssl"` + Region string `mapstructure:"region"` + Domain string `mapstructure:"domain"` // 自定义域名,如 s3.carrot.skin +} + +// NewS3 创建 S3 客户端 +func NewS3(cfg *S3Config) (*minio.Client, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + client, err := minio.New(cfg.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), + Secure: cfg.UseSSL, + }) + if err != nil { + return nil, fmt.Errorf("failed to create S3 client: %w", err) + } + + exists, err := client.BucketExists(ctx, cfg.Bucket) + if err != nil { + return nil, fmt.Errorf("failed to check bucket: %w", err) + } + + if !exists { + if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{ + Region: cfg.Region, + }); err != nil { + return nil, fmt.Errorf("failed to create bucket: %w", err) + } + } + + return client, nil +} diff --git a/internal/dto/converter.go b/internal/dto/converter.go index d74b446..07bc4d9 100644 --- a/internal/dto/converter.go +++ b/internal/dto/converter.go @@ -1,899 +1,13 @@ package dto -import ( - "carrot_bbs/internal/model" - "carrot_bbs/internal/pkg/utils" - "context" - "encoding/json" - "strconv" -) - -// ==================== User 转换 ==================== - -// getAvatarOrDefault 获取头像URL,如果为空则返回在线头像生成服务的URL -func getAvatarOrDefault(user *model.User) string { - return utils.GetAvatarOrDefault(user.Username, user.Nickname, user.Avatar) -} - -// ConvertUserToResponse 将User转换为UserResponse -func ConvertUserToResponse(user *model.User) *UserResponse { - if user == nil { - return nil - } - return &UserResponse{ - ID: user.ID, - Username: user.Username, - Nickname: user.Nickname, - Email: user.Email, - Phone: user.Phone, - EmailVerified: user.EmailVerified, - Avatar: getAvatarOrDefault(user), - CoverURL: user.CoverURL, - Bio: user.Bio, - Website: user.Website, - Location: user.Location, - PostsCount: user.PostsCount, - FollowersCount: user.FollowersCount, - FollowingCount: user.FollowingCount, - CreatedAt: FormatTime(user.CreatedAt), - } -} - -// ConvertUserToResponseWithFollowing 将User转换为UserResponse(包含关注状态) -func ConvertUserToResponseWithFollowing(user *model.User, isFollowing bool) *UserResponse { - if user == nil { - return nil - } - return &UserResponse{ - ID: user.ID, - Username: user.Username, - Nickname: user.Nickname, - Email: user.Email, - Phone: user.Phone, - EmailVerified: user.EmailVerified, - Avatar: getAvatarOrDefault(user), - CoverURL: user.CoverURL, - Bio: user.Bio, - Website: user.Website, - Location: user.Location, - PostsCount: user.PostsCount, - FollowersCount: user.FollowersCount, - FollowingCount: user.FollowingCount, - IsFollowing: isFollowing, - IsFollowingMe: false, // 默认false,需要单独计算 - CreatedAt: FormatTime(user.CreatedAt), - } -} - -// ConvertUserToResponseWithPostsCount 将User转换为UserResponse(使用实时计算的帖子数量) -func ConvertUserToResponseWithPostsCount(user *model.User, postsCount int) *UserResponse { - if user == nil { - return nil - } - return &UserResponse{ - ID: user.ID, - Username: user.Username, - Nickname: user.Nickname, - Email: user.Email, - Phone: user.Phone, - EmailVerified: user.EmailVerified, - Avatar: getAvatarOrDefault(user), - CoverURL: user.CoverURL, - Bio: user.Bio, - Website: user.Website, - Location: user.Location, - PostsCount: postsCount, - FollowersCount: user.FollowersCount, - FollowingCount: user.FollowingCount, - CreatedAt: FormatTime(user.CreatedAt), - } -} - -// ConvertUserToResponseWithMutualFollow 将User转换为UserResponse(包含双向关注状态) -func ConvertUserToResponseWithMutualFollow(user *model.User, isFollowing, isFollowingMe bool) *UserResponse { - if user == nil { - return nil - } - return &UserResponse{ - ID: user.ID, - Username: user.Username, - Nickname: user.Nickname, - Email: user.Email, - Phone: user.Phone, - EmailVerified: user.EmailVerified, - Avatar: getAvatarOrDefault(user), - CoverURL: user.CoverURL, - Bio: user.Bio, - Website: user.Website, - Location: user.Location, - PostsCount: user.PostsCount, - FollowersCount: user.FollowersCount, - FollowingCount: user.FollowingCount, - IsFollowing: isFollowing, - IsFollowingMe: isFollowingMe, - CreatedAt: FormatTime(user.CreatedAt), - } -} - -// ConvertUserToResponseWithMutualFollowAndPostsCount 将User转换为UserResponse(包含双向关注状态和实时计算的帖子数量) -func ConvertUserToResponseWithMutualFollowAndPostsCount(user *model.User, isFollowing, isFollowingMe bool, postsCount int) *UserResponse { - if user == nil { - return nil - } - return &UserResponse{ - ID: user.ID, - Username: user.Username, - Nickname: user.Nickname, - Email: user.Email, - Phone: user.Phone, - EmailVerified: user.EmailVerified, - Avatar: getAvatarOrDefault(user), - CoverURL: user.CoverURL, - Bio: user.Bio, - Website: user.Website, - Location: user.Location, - PostsCount: postsCount, - FollowersCount: user.FollowersCount, - FollowingCount: user.FollowingCount, - IsFollowing: isFollowing, - IsFollowingMe: isFollowingMe, - CreatedAt: FormatTime(user.CreatedAt), - } -} - -// ConvertUserToDetailResponse 将User转换为UserDetailResponse -func ConvertUserToDetailResponse(user *model.User) *UserDetailResponse { - if user == nil { - return nil - } - return &UserDetailResponse{ - ID: user.ID, - Username: user.Username, - Nickname: user.Nickname, - Email: user.Email, - EmailVerified: user.EmailVerified, - Avatar: getAvatarOrDefault(user), - CoverURL: user.CoverURL, - Bio: user.Bio, - Website: user.Website, - Location: user.Location, - PostsCount: user.PostsCount, - FollowersCount: user.FollowersCount, - FollowingCount: user.FollowingCount, - IsVerified: user.IsVerified, - CreatedAt: FormatTime(user.CreatedAt), - } -} - -// ConvertUserToDetailResponseWithPostsCount 将User转换为UserDetailResponse(使用实时计算的帖子数量) -func ConvertUserToDetailResponseWithPostsCount(user *model.User, postsCount int) *UserDetailResponse { - if user == nil { - return nil - } - return &UserDetailResponse{ - ID: user.ID, - Username: user.Username, - Nickname: user.Nickname, - Email: user.Email, - EmailVerified: user.EmailVerified, - Phone: user.Phone, // 仅当前用户自己可见 - Avatar: getAvatarOrDefault(user), - CoverURL: user.CoverURL, - Bio: user.Bio, - Website: user.Website, - Location: user.Location, - PostsCount: postsCount, - FollowersCount: user.FollowersCount, - FollowingCount: user.FollowingCount, - IsVerified: user.IsVerified, - CreatedAt: FormatTime(user.CreatedAt), - } -} - -// ConvertUsersToResponse 将User列表转换为响应列表 -func ConvertUsersToResponse(users []*model.User) []*UserResponse { - result := make([]*UserResponse, 0, len(users)) - for _, user := range users { - result = append(result, ConvertUserToResponse(user)) - } - return result -} - -// ConvertUsersToResponseWithMutualFollow 将User列表转换为响应列表(包含双向关注状态) -// followingStatusMap: key是用户ID,value是[isFollowing, isFollowingMe] -func ConvertUsersToResponseWithMutualFollow(users []*model.User, followingStatusMap map[string][2]bool) []*UserResponse { - result := make([]*UserResponse, 0, len(users)) - for _, user := range users { - status, ok := followingStatusMap[user.ID] - if ok { - result = append(result, ConvertUserToResponseWithMutualFollow(user, status[0], status[1])) - } else { - result = append(result, ConvertUserToResponse(user)) - } - } - return result -} - -// ConvertUsersToResponseWithMutualFollowAndPostsCount 将User列表转换为响应列表(包含双向关注状态和实时计算的帖子数量) -// followingStatusMap: key是用户ID,value是[isFollowing, isFollowingMe] -// postsCountMap: key是用户ID,value是帖子数量 -func ConvertUsersToResponseWithMutualFollowAndPostsCount(users []*model.User, followingStatusMap map[string][2]bool, postsCountMap map[string]int64) []*UserResponse { - result := make([]*UserResponse, 0, len(users)) - for _, user := range users { - status, hasStatus := followingStatusMap[user.ID] - postsCount, hasPostsCount := postsCountMap[user.ID] - - // 如果没有帖子数量,使用数据库中的值 - if !hasPostsCount { - postsCount = int64(user.PostsCount) - } - - if hasStatus { - result = append(result, ConvertUserToResponseWithMutualFollowAndPostsCount(user, status[0], status[1], int(postsCount))) - } else { - result = append(result, ConvertUserToResponseWithPostsCount(user, int(postsCount))) - } - } - return result -} - -// ==================== Post 转换 ==================== - -// ConvertPostImageToResponse 将PostImage转换为PostImageResponse -func ConvertPostImageToResponse(img *model.PostImage) PostImageResponse { - if img == nil { - return PostImageResponse{} - } - return PostImageResponse{ - ID: img.ID, - URL: img.URL, - ThumbnailURL: img.ThumbnailURL, - Width: img.Width, - Height: img.Height, - } -} - -// ConvertPostImagesToResponse 将PostImage列表转换为响应列表 -func ConvertPostImagesToResponse(images []model.PostImage) []PostImageResponse { - result := make([]PostImageResponse, 0, len(images)) - for i := range images { - result = append(result, ConvertPostImageToResponse(&images[i])) - } - return result -} - -// ConvertPostToResponse 将Post转换为PostResponse(列表用) -func ConvertPostToResponse(post *model.Post, isLiked, isFavorited bool) *PostResponse { - if post == nil { - return nil - } - - images := make([]PostImageResponse, 0) - for _, img := range post.Images { - images = append(images, ConvertPostImageToResponse(&img)) - } - - var author *UserResponse - if post.User != nil { - author = ConvertUserToResponse(post.User) - } - - return &PostResponse{ - ID: post.ID, - UserID: post.UserID, - Title: post.Title, - Content: post.Content, - Images: images, - Status: string(post.Status), - LikesCount: post.LikesCount, - CommentsCount: post.CommentsCount, - FavoritesCount: post.FavoritesCount, - SharesCount: post.SharesCount, - ViewsCount: post.ViewsCount, - IsPinned: post.IsPinned, - IsLocked: post.IsLocked, - IsVote: post.IsVote, - CreatedAt: FormatTime(post.CreatedAt), - UpdatedAt: FormatTime(post.UpdatedAt), - Author: author, - IsLiked: isLiked, - IsFavorited: isFavorited, - } -} - -// ConvertPostToDetailResponse 将Post转换为PostDetailResponse -func ConvertPostToDetailResponse(post *model.Post, isLiked, isFavorited bool) *PostDetailResponse { - if post == nil { - return nil - } - - images := make([]PostImageResponse, 0) - for _, img := range post.Images { - images = append(images, ConvertPostImageToResponse(&img)) - } - - var author *UserResponse - if post.User != nil { - author = ConvertUserToResponse(post.User) - } - - return &PostDetailResponse{ - ID: post.ID, - UserID: post.UserID, - Title: post.Title, - Content: post.Content, - Images: images, - Status: string(post.Status), - LikesCount: post.LikesCount, - CommentsCount: post.CommentsCount, - FavoritesCount: post.FavoritesCount, - SharesCount: post.SharesCount, - ViewsCount: post.ViewsCount, - IsPinned: post.IsPinned, - IsLocked: post.IsLocked, - IsVote: post.IsVote, - CreatedAt: FormatTime(post.CreatedAt), - UpdatedAt: FormatTime(post.UpdatedAt), - Author: author, - IsLiked: isLiked, - IsFavorited: isFavorited, - } -} - -// ConvertPostsToResponse 将Post列表转换为响应列表(每个帖子独立检查点赞/收藏状态) -func ConvertPostsToResponse(posts []*model.Post, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse { - result := make([]*PostResponse, 0, len(posts)) - for _, post := range posts { - isLiked := false - isFavorited := false - if isLikedMap != nil { - isLiked = isLikedMap[post.ID] - } - if isFavoritedMap != nil { - isFavorited = isFavoritedMap[post.ID] - } - result = append(result, ConvertPostToResponse(post, isLiked, isFavorited)) - } - return result -} - -// BuildPostResponse 构建单个帖子响应(包含交互状态) -// 这是一个语义化的辅助函数,便于 Handler 层调用 -func BuildPostResponse(post *model.Post, isLiked, isFavorited bool) *PostResponse { - return ConvertPostToResponse(post, isLiked, isFavorited) -} - -// BuildPostsWithInteractionResponse 批量构建帖子响应(包含交互状态) -// 这是一个语义化的辅助函数,便于 Handler 层调用 -func BuildPostsWithInteractionResponse(posts []*model.Post, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse { - return ConvertPostsToResponse(posts, isLikedMap, isFavoritedMap) -} - -// ==================== Comment 转换 ==================== - -// ConvertCommentToResponse 将Comment转换为CommentResponse -func ConvertCommentToResponse(comment *model.Comment, isLiked bool) *CommentResponse { - if comment == nil { - return nil - } - - var author *UserResponse - if comment.User != nil { - author = ConvertUserToResponse(comment.User) - } - - // 转换子回复(扁平化结构) - var replies []*CommentResponse - if len(comment.Replies) > 0 { - replies = make([]*CommentResponse, 0, len(comment.Replies)) - for _, reply := range comment.Replies { - replies = append(replies, ConvertCommentToResponse(reply, false)) - } - } - - // TargetID 就是 ParentID,前端根据这个 ID 找到被回复用户的昵称 - var targetID *string - if comment.ParentID != nil && *comment.ParentID != "" { - targetID = comment.ParentID - } - - // 解析图片JSON - var images []CommentImageResponse - if comment.Images != "" { - var urlList []string - if err := json.Unmarshal([]byte(comment.Images), &urlList); err == nil { - images = make([]CommentImageResponse, 0, len(urlList)) - for _, url := range urlList { - images = append(images, CommentImageResponse{URL: url}) - } - } - } - - return &CommentResponse{ - ID: comment.ID, - PostID: comment.PostID, - UserID: comment.UserID, - ParentID: comment.ParentID, - RootID: comment.RootID, - Content: comment.Content, - Images: images, - LikesCount: comment.LikesCount, - RepliesCount: comment.RepliesCount, - CreatedAt: FormatTime(comment.CreatedAt), - Author: author, - IsLiked: isLiked, - TargetID: targetID, - Replies: replies, - } -} - -// ConvertCommentsToResponse 将Comment列表转换为响应列表 -func ConvertCommentsToResponse(comments []*model.Comment, isLiked bool) []*CommentResponse { - result := make([]*CommentResponse, 0, len(comments)) - for _, comment := range comments { - result = append(result, ConvertCommentToResponse(comment, isLiked)) - } - return result -} - -// IsLikedChecker 点赞状态检查器接口 -type IsLikedChecker interface { - IsLiked(ctx context.Context, commentID, userID string) bool -} - -// ConvertCommentToResponseWithUser 将Comment转换为CommentResponse(根据用户ID检查点赞状态) -func ConvertCommentToResponseWithUser(comment *model.Comment, userID string, checker IsLikedChecker) *CommentResponse { - if comment == nil { - return nil - } - - // 检查当前用户是否点赞了该评论 - isLiked := false - if userID != "" && checker != nil { - isLiked = checker.IsLiked(context.Background(), comment.ID, userID) - } - - var author *UserResponse - if comment.User != nil { - author = ConvertUserToResponse(comment.User) - } - - // 转换子回复(扁平化结构),递归检查点赞状态 - var replies []*CommentResponse - if len(comment.Replies) > 0 { - replies = make([]*CommentResponse, 0, len(comment.Replies)) - for _, reply := range comment.Replies { - replies = append(replies, ConvertCommentToResponseWithUser(reply, userID, checker)) - } - } - - // TargetID 就是 ParentID,前端根据这个 ID 找到被回复用户的昵称 - var targetID *string - if comment.ParentID != nil && *comment.ParentID != "" { - targetID = comment.ParentID - } - - // 解析图片JSON - var images []CommentImageResponse - if comment.Images != "" { - var urlList []string - if err := json.Unmarshal([]byte(comment.Images), &urlList); err == nil { - images = make([]CommentImageResponse, 0, len(urlList)) - for _, url := range urlList { - images = append(images, CommentImageResponse{URL: url}) - } - } - } - - return &CommentResponse{ - ID: comment.ID, - PostID: comment.PostID, - UserID: comment.UserID, - ParentID: comment.ParentID, - RootID: comment.RootID, - Content: comment.Content, - Images: images, - LikesCount: comment.LikesCount, - RepliesCount: comment.RepliesCount, - CreatedAt: FormatTime(comment.CreatedAt), - Author: author, - IsLiked: isLiked, - TargetID: targetID, - Replies: replies, - } -} - -// ConvertCommentsToResponseWithUser 将Comment列表转换为响应列表(根据用户ID检查点赞状态) -func ConvertCommentsToResponseWithUser(comments []*model.Comment, userID string, checker IsLikedChecker) []*CommentResponse { - result := make([]*CommentResponse, 0, len(comments)) - for _, comment := range comments { - result = append(result, ConvertCommentToResponseWithUser(comment, userID, checker)) - } - return result -} - -// ==================== Notification 转换 ==================== - -// ConvertNotificationToResponse 将Notification转换为NotificationResponse -func ConvertNotificationToResponse(notification *model.Notification) *NotificationResponse { - if notification == nil { - return nil - } - return &NotificationResponse{ - ID: notification.ID, - UserID: notification.UserID, - Type: string(notification.Type), - Title: notification.Title, - Content: notification.Content, - Data: notification.Data, - IsRead: notification.IsRead, - CreatedAt: FormatTime(notification.CreatedAt), - } -} - -// ConvertNotificationsToResponse 将Notification列表转换为响应列表 -func ConvertNotificationsToResponse(notifications []*model.Notification) []*NotificationResponse { - result := make([]*NotificationResponse, 0, len(notifications)) - for _, n := range notifications { - result = append(result, ConvertNotificationToResponse(n)) - } - return result -} - -// ==================== Message 转换 ==================== - -// ConvertMessageToResponse 将Message转换为MessageResponse -func ConvertMessageToResponse(message *model.Message) *MessageResponse { - if message == nil { - return nil - } - - // 直接使用 segments,不需要解析 - segments := make(model.MessageSegments, len(message.Segments)) - for i, seg := range message.Segments { - segments[i] = model.MessageSegment{ - Type: seg.Type, - Data: seg.Data, - } - } - - return &MessageResponse{ - ID: message.ID, - ConversationID: message.ConversationID, - SenderID: message.SenderID, - Seq: message.Seq, - Segments: segments, - ReplyToID: message.ReplyToID, - Status: string(message.Status), - Category: string(message.Category), - CreatedAt: FormatTime(message.CreatedAt), - } -} - -// ConvertConversationToResponse 将Conversation转换为ConversationResponse -// participants: 会话参与者列表(用户信息) -// unreadCount: 当前用户的未读消息数 -// lastMessage: 最后一条消息 -func ConvertConversationToResponse(conv *model.Conversation, participants []*model.User, unreadCount int, lastMessage *model.Message, isPinned bool) *ConversationResponse { - if conv == nil { - return nil - } - - var participantResponses []*UserResponse - for _, p := range participants { - participantResponses = append(participantResponses, ConvertUserToResponse(p)) - } - - // 转换群组信息 - var groupResponse *GroupResponse - if conv.Group != nil { - groupResponse = GroupToResponse(conv.Group) - } - - return &ConversationResponse{ - ID: conv.ID, - Type: string(conv.Type), - IsPinned: isPinned, - Group: groupResponse, - LastSeq: conv.LastSeq, - LastMessage: ConvertMessageToResponse(lastMessage), - LastMessageAt: FormatTimePointer(conv.LastMsgTime), - UnreadCount: unreadCount, - Participants: participantResponses, - CreatedAt: FormatTime(conv.CreatedAt), - UpdatedAt: FormatTime(conv.UpdatedAt), - } -} - -// ConvertConversationToDetailResponse 将Conversation转换为ConversationDetailResponse -func ConvertConversationToDetailResponse(conv *model.Conversation, participants []*model.User, unreadCount int64, lastMessage *model.Message, myLastReadSeq int64, otherLastReadSeq int64, isPinned bool) *ConversationDetailResponse { - if conv == nil { - return nil - } - - var participantResponses []*UserResponse - for _, p := range participants { - participantResponses = append(participantResponses, ConvertUserToResponse(p)) - } - - return &ConversationDetailResponse{ - ID: conv.ID, - Type: string(conv.Type), - IsPinned: isPinned, - LastSeq: conv.LastSeq, - LastMessage: ConvertMessageToResponse(lastMessage), - LastMessageAt: FormatTimePointer(conv.LastMsgTime), - UnreadCount: unreadCount, - Participants: participantResponses, - MyLastReadSeq: myLastReadSeq, - OtherLastReadSeq: otherLastReadSeq, - CreatedAt: FormatTime(conv.CreatedAt), - UpdatedAt: FormatTime(conv.UpdatedAt), - } -} - -// ConvertMessagesToResponse 将Message列表转换为响应列表 -func ConvertMessagesToResponse(messages []*model.Message) []*MessageResponse { - result := make([]*MessageResponse, 0, len(messages)) - for _, msg := range messages { - result = append(result, ConvertMessageToResponse(msg)) - } - return result -} - -// ConvertConversationsToResponse 将Conversation列表转换为响应列表 -func ConvertConversationsToResponse(convs []*model.Conversation) []*ConversationResponse { - result := make([]*ConversationResponse, 0, len(convs)) - for _, conv := range convs { - result = append(result, ConvertConversationToResponse(conv, nil, 0, nil, false)) - } - return result -} - -// ==================== PushRecord 转换 ==================== - -// PushRecordToResponse 将PushRecord转换为PushRecordResponse -func PushRecordToResponse(record *model.PushRecord) *PushRecordResponse { - if record == nil { - return nil - } - resp := &PushRecordResponse{ - ID: record.ID, - MessageID: record.MessageID, - PushChannel: string(record.PushChannel), - PushStatus: string(record.PushStatus), - RetryCount: record.RetryCount, - CreatedAt: record.CreatedAt, - } - if record.PushedAt != nil { - resp.PushedAt = *record.PushedAt - } - if record.DeliveredAt != nil { - resp.DeliveredAt = *record.DeliveredAt - } - return resp -} - -// PushRecordsToResponse 将PushRecord列表转换为响应列表 -func PushRecordsToResponse(records []*model.PushRecord) []*PushRecordResponse { - result := make([]*PushRecordResponse, 0, len(records)) - for _, record := range records { - result = append(result, PushRecordToResponse(record)) - } - return result -} - -// ==================== DeviceToken 转换 ==================== - -// DeviceTokenToResponse 将DeviceToken转换为DeviceTokenResponse -func DeviceTokenToResponse(token *model.DeviceToken) *DeviceTokenResponse { - if token == nil { - return nil - } - resp := &DeviceTokenResponse{ - ID: token.ID, - DeviceID: token.DeviceID, - DeviceType: string(token.DeviceType), - IsActive: token.IsActive, - DeviceName: token.DeviceName, - CreatedAt: token.CreatedAt, - } - if token.LastUsedAt != nil { - resp.LastUsedAt = *token.LastUsedAt - } - return resp -} - -// DeviceTokensToResponse 将DeviceToken列表转换为响应列表 -func DeviceTokensToResponse(tokens []*model.DeviceToken) []*DeviceTokenResponse { - result := make([]*DeviceTokenResponse, 0, len(tokens)) - for _, token := range tokens { - result = append(result, DeviceTokenToResponse(token)) - } - return result -} - -// ==================== SystemMessage 转换 ==================== - -// SystemMessageToResponse 将Message转换为SystemMessageResponse -func SystemMessageToResponse(msg *model.Message) *SystemMessageResponse { - if msg == nil { - return nil - } - - // 从 segments 中提取文本内容 - content := ExtractTextContentFromModel(msg.Segments) - - resp := &SystemMessageResponse{ - ID: msg.ID, - SenderID: msg.SenderID, - ReceiverID: "", // 系统消息的接收者需要从上下文获取 - Content: content, - Category: string(msg.Category), - SystemType: string(msg.SystemType), - CreatedAt: msg.CreatedAt, - } - if msg.ExtraData != nil { - resp.ExtraData = map[string]interface{}{ - "actor_id": msg.ExtraData.ActorID, - "actor_name": msg.ExtraData.ActorName, - "avatar_url": msg.ExtraData.AvatarURL, - "target_id": msg.ExtraData.TargetID, - "target_type": msg.ExtraData.TargetType, - "action_url": msg.ExtraData.ActionURL, - "action_time": msg.ExtraData.ActionTime, - } - } - return resp -} - -// SystemMessagesToResponse 将Message列表转换为SystemMessageResponse列表 -func SystemMessagesToResponse(messages []*model.Message) []*SystemMessageResponse { - result := make([]*SystemMessageResponse, 0, len(messages)) - for _, msg := range messages { - result = append(result, SystemMessageToResponse(msg)) - } - return result -} - -// SystemNotificationToResponse 将SystemNotification转换为SystemMessageResponse -func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageResponse { - if n == nil { - return nil - } - resp := &SystemMessageResponse{ - ID: strconv.FormatInt(n.ID, 10), - SenderID: model.SystemSenderIDStr, // 系统发送者 - ReceiverID: n.ReceiverID, - Content: n.Content, - Category: "notification", - SystemType: string(n.Type), - IsRead: n.IsRead, - CreatedAt: n.CreatedAt, - } - if n.ExtraData != nil { - resp.ExtraData = map[string]interface{}{ - "actor_id": n.ExtraData.ActorID, - "actor_id_str": n.ExtraData.ActorIDStr, - "actor_name": n.ExtraData.ActorName, - "avatar_url": n.ExtraData.AvatarURL, - "target_id": n.ExtraData.TargetID, - "target_title": n.ExtraData.TargetTitle, - "target_type": n.ExtraData.TargetType, - "action_url": n.ExtraData.ActionURL, - "action_time": n.ExtraData.ActionTime, - "group_id": n.ExtraData.GroupID, - "group_name": n.ExtraData.GroupName, - "group_avatar": n.ExtraData.GroupAvatar, - "group_description": n.ExtraData.GroupDescription, - "flag": n.ExtraData.Flag, - "request_type": n.ExtraData.RequestType, - "request_status": n.ExtraData.RequestStatus, - "reason": n.ExtraData.Reason, - "target_user_id": n.ExtraData.TargetUserID, - "target_user_name": n.ExtraData.TargetUserName, - "target_user_avatar": n.ExtraData.TargetUserAvatar, - } - } - return resp -} - -// SystemNotificationsToResponse 将SystemNotification列表转换为SystemMessageResponse列表 -func SystemNotificationsToResponse(notifications []*model.SystemNotification) []*SystemMessageResponse { - result := make([]*SystemMessageResponse, 0, len(notifications)) - for _, n := range notifications { - result = append(result, SystemNotificationToResponse(n)) - } - return result -} - -// ==================== Group 转换 ==================== - -// GroupToResponse 将Group转换为GroupResponse -func GroupToResponse(group *model.Group) *GroupResponse { - if group == nil { - return nil - } - return &GroupResponse{ - ID: group.ID, - Name: group.Name, - Avatar: group.Avatar, - Description: group.Description, - OwnerID: group.OwnerID, - MemberCount: group.MemberCount, - MaxMembers: group.MaxMembers, - JoinType: int(group.JoinType), - MuteAll: group.MuteAll, - CreatedAt: FormatTime(group.CreatedAt), - } -} - -// GroupsToResponse 将Group列表转换为GroupResponse列表 -func GroupsToResponse(groups []model.Group) []*GroupResponse { - result := make([]*GroupResponse, 0, len(groups)) - for i := range groups { - result = append(result, GroupToResponse(&groups[i])) - } - return result -} - -// GroupMemberToResponse 将GroupMember转换为GroupMemberResponse -func GroupMemberToResponse(member *model.GroupMember) *GroupMemberResponse { - if member == nil { - return nil - } - return &GroupMemberResponse{ - ID: member.ID, - GroupID: member.GroupID, - UserID: member.UserID, - Role: member.Role, - Nickname: member.Nickname, - Muted: member.Muted, - JoinTime: FormatTime(member.JoinTime), - } -} - -// GroupMemberToResponseWithUser 将GroupMember转换为GroupMemberResponse(包含用户信息) -func GroupMemberToResponseWithUser(member *model.GroupMember, user *model.User) *GroupMemberResponse { - if member == nil { - return nil - } - resp := GroupMemberToResponse(member) - if user != nil { - resp.User = ConvertUserToResponse(user) - } - return resp -} - -// GroupMembersToResponse 将GroupMember列表转换为GroupMemberResponse列表 -func GroupMembersToResponse(members []model.GroupMember) []*GroupMemberResponse { - result := make([]*GroupMemberResponse, 0, len(members)) - for i := range members { - result = append(result, GroupMemberToResponse(&members[i])) - } - return result -} - -// GroupAnnouncementToResponse 将GroupAnnouncement转换为GroupAnnouncementResponse -func GroupAnnouncementToResponse(announcement *model.GroupAnnouncement) *GroupAnnouncementResponse { - if announcement == nil { - return nil - } - return &GroupAnnouncementResponse{ - ID: announcement.ID, - GroupID: announcement.GroupID, - Content: announcement.Content, - AuthorID: announcement.AuthorID, - IsPinned: announcement.IsPinned, - CreatedAt: FormatTime(announcement.CreatedAt), - } -} - -// GroupAnnouncementsToResponse 将GroupAnnouncement列表转换为GroupAnnouncementResponse列表 -func GroupAnnouncementsToResponse(announcements []model.GroupAnnouncement) []*GroupAnnouncementResponse { - result := make([]*GroupAnnouncementResponse, 0, len(announcements)) - for i := range announcements { - result = append(result, GroupAnnouncementToResponse(&announcements[i])) - } - return result -} +// converter.go - 公共转换函数 +// +// 转换函数已按领域拆分到以下文件: +// - user_converter.go: 用户相关转换 +// - post_converter.go: 帖子和评论相关转换 +// - message_converter.go: 消息、会话、推送和设备令牌相关转换 +// - group_converter.go: 群组相关转换 +// - notification_converter.go: 通知和系统消息相关转换 +// - schedule_converter.go: 日程相关转换(已存在) +// +// 所有转换函数都在 dto 包中,可以直接通过 dto.ConvertXxxToResponse 调用。 diff --git a/internal/dto/group_converter.go b/internal/dto/group_converter.go new file mode 100644 index 0000000..573cb7b --- /dev/null +++ b/internal/dto/group_converter.go @@ -0,0 +1,96 @@ +package dto + +import ( + "carrot_bbs/internal/model" +) + +// ==================== Group 转换 ==================== + +// GroupToResponse 将Group转换为GroupResponse +func GroupToResponse(group *model.Group) *GroupResponse { + if group == nil { + return nil + } + return &GroupResponse{ + ID: group.ID, + Name: group.Name, + Avatar: group.Avatar, + Description: group.Description, + OwnerID: group.OwnerID, + MemberCount: group.MemberCount, + MaxMembers: group.MaxMembers, + JoinType: int(group.JoinType), + MuteAll: group.MuteAll, + CreatedAt: FormatTime(group.CreatedAt), + } +} + +// GroupsToResponse 将Group列表转换为GroupResponse列表 +func GroupsToResponse(groups []model.Group) []*GroupResponse { + result := make([]*GroupResponse, 0, len(groups)) + for i := range groups { + result = append(result, GroupToResponse(&groups[i])) + } + return result +} + +// GroupMemberToResponse 将GroupMember转换为GroupMemberResponse +func GroupMemberToResponse(member *model.GroupMember) *GroupMemberResponse { + if member == nil { + return nil + } + return &GroupMemberResponse{ + ID: member.ID, + GroupID: member.GroupID, + UserID: member.UserID, + Role: member.Role, + Nickname: member.Nickname, + Muted: member.Muted, + JoinTime: FormatTime(member.JoinTime), + } +} + +// GroupMemberToResponseWithUser 将GroupMember转换为GroupMemberResponse(包含用户信息) +func GroupMemberToResponseWithUser(member *model.GroupMember, user *model.User) *GroupMemberResponse { + if member == nil { + return nil + } + resp := GroupMemberToResponse(member) + if user != nil { + resp.User = ConvertUserToResponse(user) + } + return resp +} + +// GroupMembersToResponse 将GroupMember列表转换为GroupMemberResponse列表 +func GroupMembersToResponse(members []model.GroupMember) []*GroupMemberResponse { + result := make([]*GroupMemberResponse, 0, len(members)) + for i := range members { + result = append(result, GroupMemberToResponse(&members[i])) + } + return result +} + +// GroupAnnouncementToResponse 将GroupAnnouncement转换为GroupAnnouncementResponse +func GroupAnnouncementToResponse(announcement *model.GroupAnnouncement) *GroupAnnouncementResponse { + if announcement == nil { + return nil + } + return &GroupAnnouncementResponse{ + ID: announcement.ID, + GroupID: announcement.GroupID, + Content: announcement.Content, + AuthorID: announcement.AuthorID, + IsPinned: announcement.IsPinned, + CreatedAt: FormatTime(announcement.CreatedAt), + } +} + +// GroupAnnouncementsToResponse 将GroupAnnouncement列表转换为GroupAnnouncementResponse列表 +func GroupAnnouncementsToResponse(announcements []model.GroupAnnouncement) []*GroupAnnouncementResponse { + result := make([]*GroupAnnouncementResponse, 0, len(announcements)) + for i := range announcements { + result = append(result, GroupAnnouncementToResponse(&announcements[i])) + } + return result +} diff --git a/internal/dto/message_converter.go b/internal/dto/message_converter.go new file mode 100644 index 0000000..d8319f1 --- /dev/null +++ b/internal/dto/message_converter.go @@ -0,0 +1,178 @@ +package dto + +import ( + "carrot_bbs/internal/model" +) + +// ==================== Message 转换 ==================== + +// ConvertMessageToResponse 将Message转换为MessageResponse +func ConvertMessageToResponse(message *model.Message) *MessageResponse { + if message == nil { + return nil + } + + // 直接使用 segments,不需要解析 + segments := make(model.MessageSegments, len(message.Segments)) + for i, seg := range message.Segments { + segments[i] = model.MessageSegment{ + Type: seg.Type, + Data: seg.Data, + } + } + + return &MessageResponse{ + ID: message.ID, + ConversationID: message.ConversationID, + SenderID: message.SenderID, + Seq: message.Seq, + Segments: segments, + ReplyToID: message.ReplyToID, + Status: string(message.Status), + Category: string(message.Category), + CreatedAt: FormatTime(message.CreatedAt), + } +} + +// ConvertConversationToResponse 将Conversation转换为ConversationResponse +// participants: 会话参与者列表(用户信息) +// unreadCount: 当前用户的未读消息数 +// lastMessage: 最后一条消息 +func ConvertConversationToResponse(conv *model.Conversation, participants []*model.User, unreadCount int, lastMessage *model.Message, isPinned bool) *ConversationResponse { + if conv == nil { + return nil + } + + var participantResponses []*UserResponse + for _, p := range participants { + participantResponses = append(participantResponses, ConvertUserToResponse(p)) + } + + // 转换群组信息 + var groupResponse *GroupResponse + if conv.Group != nil { + groupResponse = GroupToResponse(conv.Group) + } + + return &ConversationResponse{ + ID: conv.ID, + Type: string(conv.Type), + IsPinned: isPinned, + Group: groupResponse, + LastSeq: conv.LastSeq, + LastMessage: ConvertMessageToResponse(lastMessage), + LastMessageAt: FormatTimePointer(conv.LastMsgTime), + UnreadCount: unreadCount, + Participants: participantResponses, + CreatedAt: FormatTime(conv.CreatedAt), + UpdatedAt: FormatTime(conv.UpdatedAt), + } +} + +// ConvertConversationToDetailResponse 将Conversation转换为ConversationDetailResponse +func ConvertConversationToDetailResponse(conv *model.Conversation, participants []*model.User, unreadCount int64, lastMessage *model.Message, myLastReadSeq int64, otherLastReadSeq int64, isPinned bool) *ConversationDetailResponse { + if conv == nil { + return nil + } + + var participantResponses []*UserResponse + for _, p := range participants { + participantResponses = append(participantResponses, ConvertUserToResponse(p)) + } + + return &ConversationDetailResponse{ + ID: conv.ID, + Type: string(conv.Type), + IsPinned: isPinned, + LastSeq: conv.LastSeq, + LastMessage: ConvertMessageToResponse(lastMessage), + LastMessageAt: FormatTimePointer(conv.LastMsgTime), + UnreadCount: unreadCount, + Participants: participantResponses, + MyLastReadSeq: myLastReadSeq, + OtherLastReadSeq: otherLastReadSeq, + CreatedAt: FormatTime(conv.CreatedAt), + UpdatedAt: FormatTime(conv.UpdatedAt), + } +} + +// ConvertMessagesToResponse 将Message列表转换为响应列表 +func ConvertMessagesToResponse(messages []*model.Message) []*MessageResponse { + result := make([]*MessageResponse, 0, len(messages)) + for _, msg := range messages { + result = append(result, ConvertMessageToResponse(msg)) + } + return result +} + +// ConvertConversationsToResponse 将Conversation列表转换为响应列表 +func ConvertConversationsToResponse(convs []*model.Conversation) []*ConversationResponse { + result := make([]*ConversationResponse, 0, len(convs)) + for _, conv := range convs { + result = append(result, ConvertConversationToResponse(conv, nil, 0, nil, false)) + } + return result +} + +// ==================== PushRecord 转换 ==================== + +// PushRecordToResponse 将PushRecord转换为PushRecordResponse +func PushRecordToResponse(record *model.PushRecord) *PushRecordResponse { + if record == nil { + return nil + } + resp := &PushRecordResponse{ + ID: record.ID, + MessageID: record.MessageID, + PushChannel: string(record.PushChannel), + PushStatus: string(record.PushStatus), + RetryCount: record.RetryCount, + CreatedAt: record.CreatedAt, + } + if record.PushedAt != nil { + resp.PushedAt = *record.PushedAt + } + if record.DeliveredAt != nil { + resp.DeliveredAt = *record.DeliveredAt + } + return resp +} + +// PushRecordsToResponse 将PushRecord列表转换为响应列表 +func PushRecordsToResponse(records []*model.PushRecord) []*PushRecordResponse { + result := make([]*PushRecordResponse, 0, len(records)) + for _, record := range records { + result = append(result, PushRecordToResponse(record)) + } + return result +} + +// ==================== DeviceToken 转换 ==================== + +// DeviceTokenToResponse 将DeviceToken转换为DeviceTokenResponse +func DeviceTokenToResponse(token *model.DeviceToken) *DeviceTokenResponse { + if token == nil { + return nil + } + resp := &DeviceTokenResponse{ + ID: token.ID, + DeviceID: token.DeviceID, + DeviceType: string(token.DeviceType), + IsActive: token.IsActive, + DeviceName: token.DeviceName, + CreatedAt: token.CreatedAt, + } + if token.LastUsedAt != nil { + resp.LastUsedAt = *token.LastUsedAt + } + return resp +} + +// DeviceTokensToResponse 将DeviceToken列表转换为响应列表 +func DeviceTokensToResponse(tokens []*model.DeviceToken) []*DeviceTokenResponse { + result := make([]*DeviceTokenResponse, 0, len(tokens)) + for _, token := range tokens { + result = append(result, DeviceTokenToResponse(token)) + } + return result +} diff --git a/internal/dto/notification_converter.go b/internal/dto/notification_converter.go new file mode 100644 index 0000000..102cf9f --- /dev/null +++ b/internal/dto/notification_converter.go @@ -0,0 +1,128 @@ +package dto + +import ( + "carrot_bbs/internal/model" + "strconv" +) + +// ==================== Notification 转换 ==================== + +// ConvertNotificationToResponse 将Notification转换为NotificationResponse +func ConvertNotificationToResponse(notification *model.Notification) *NotificationResponse { + if notification == nil { + return nil + } + return &NotificationResponse{ + ID: notification.ID, + UserID: notification.UserID, + Type: string(notification.Type), + Title: notification.Title, + Content: notification.Content, + Data: notification.Data, + IsRead: notification.IsRead, + CreatedAt: FormatTime(notification.CreatedAt), + } +} + +// ConvertNotificationsToResponse 将Notification列表转换为响应列表 +func ConvertNotificationsToResponse(notifications []*model.Notification) []*NotificationResponse { + result := make([]*NotificationResponse, 0, len(notifications)) + for _, n := range notifications { + result = append(result, ConvertNotificationToResponse(n)) + } + return result +} + +// ==================== SystemMessage 转换 ==================== + +// SystemMessageToResponse 将Message转换为SystemMessageResponse +func SystemMessageToResponse(msg *model.Message) *SystemMessageResponse { + if msg == nil { + return nil + } + + // 从 segments 中提取文本内容 + content := ExtractTextContentFromModel(msg.Segments) + + resp := &SystemMessageResponse{ + ID: msg.ID, + SenderID: msg.SenderID, + ReceiverID: "", // 系统消息的接收者需要从上下文获取 + Content: content, + Category: string(msg.Category), + SystemType: string(msg.SystemType), + CreatedAt: msg.CreatedAt, + } + if msg.ExtraData != nil { + resp.ExtraData = map[string]interface{}{ + "actor_id": msg.ExtraData.ActorID, + "actor_name": msg.ExtraData.ActorName, + "avatar_url": msg.ExtraData.AvatarURL, + "target_id": msg.ExtraData.TargetID, + "target_type": msg.ExtraData.TargetType, + "action_url": msg.ExtraData.ActionURL, + "action_time": msg.ExtraData.ActionTime, + } + } + return resp +} + +// SystemMessagesToResponse 将Message列表转换为SystemMessageResponse列表 +func SystemMessagesToResponse(messages []*model.Message) []*SystemMessageResponse { + result := make([]*SystemMessageResponse, 0, len(messages)) + for _, msg := range messages { + result = append(result, SystemMessageToResponse(msg)) + } + return result +} + +// SystemNotificationToResponse 将SystemNotification转换为SystemMessageResponse +func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageResponse { + if n == nil { + return nil + } + resp := &SystemMessageResponse{ + ID: strconv.FormatInt(n.ID, 10), + SenderID: model.SystemSenderIDStr, // 系统发送者 + ReceiverID: n.ReceiverID, + Content: n.Content, + Category: "notification", + SystemType: string(n.Type), + IsRead: n.IsRead, + CreatedAt: n.CreatedAt, + } + if n.ExtraData != nil { + resp.ExtraData = map[string]interface{}{ + "actor_id": n.ExtraData.ActorID, + "actor_id_str": n.ExtraData.ActorIDStr, + "actor_name": n.ExtraData.ActorName, + "avatar_url": n.ExtraData.AvatarURL, + "target_id": n.ExtraData.TargetID, + "target_title": n.ExtraData.TargetTitle, + "target_type": n.ExtraData.TargetType, + "action_url": n.ExtraData.ActionURL, + "action_time": n.ExtraData.ActionTime, + "group_id": n.ExtraData.GroupID, + "group_name": n.ExtraData.GroupName, + "group_avatar": n.ExtraData.GroupAvatar, + "group_description": n.ExtraData.GroupDescription, + "flag": n.ExtraData.Flag, + "request_type": n.ExtraData.RequestType, + "request_status": n.ExtraData.RequestStatus, + "reason": n.ExtraData.Reason, + "target_user_id": n.ExtraData.TargetUserID, + "target_user_name": n.ExtraData.TargetUserName, + "target_user_avatar": n.ExtraData.TargetUserAvatar, + } + } + return resp +} + +// SystemNotificationsToResponse 将SystemNotification列表转换为SystemMessageResponse列表 +func SystemNotificationsToResponse(notifications []*model.SystemNotification) []*SystemMessageResponse { + result := make([]*SystemMessageResponse, 0, len(notifications)) + for _, n := range notifications { + result = append(result, SystemNotificationToResponse(n)) + } + return result +} diff --git a/internal/dto/post_converter.go b/internal/dto/post_converter.go new file mode 100644 index 0000000..c89b695 --- /dev/null +++ b/internal/dto/post_converter.go @@ -0,0 +1,282 @@ +package dto + +import ( + "carrot_bbs/internal/model" + "context" + "encoding/json" +) + +// ==================== Post 转换 ==================== + +// ConvertPostImageToResponse 将PostImage转换为PostImageResponse +func ConvertPostImageToResponse(img *model.PostImage) PostImageResponse { + if img == nil { + return PostImageResponse{} + } + return PostImageResponse{ + ID: img.ID, + URL: img.URL, + ThumbnailURL: img.ThumbnailURL, + Width: img.Width, + Height: img.Height, + } +} + +// ConvertPostImagesToResponse 将PostImage列表转换为响应列表 +func ConvertPostImagesToResponse(images []model.PostImage) []PostImageResponse { + result := make([]PostImageResponse, 0, len(images)) + for i := range images { + result = append(result, ConvertPostImageToResponse(&images[i])) + } + return result +} + +// ConvertPostToResponse 将Post转换为PostResponse(列表用) +func ConvertPostToResponse(post *model.Post, isLiked, isFavorited bool) *PostResponse { + if post == nil { + return nil + } + + images := make([]PostImageResponse, 0) + for _, img := range post.Images { + images = append(images, ConvertPostImageToResponse(&img)) + } + + var author *UserResponse + if post.User != nil { + author = ConvertUserToResponse(post.User) + } + + return &PostResponse{ + ID: post.ID, + UserID: post.UserID, + Title: post.Title, + Content: post.Content, + Images: images, + Status: string(post.Status), + LikesCount: post.LikesCount, + CommentsCount: post.CommentsCount, + FavoritesCount: post.FavoritesCount, + SharesCount: post.SharesCount, + ViewsCount: post.ViewsCount, + IsPinned: post.IsPinned, + IsLocked: post.IsLocked, + IsVote: post.IsVote, + CreatedAt: FormatTime(post.CreatedAt), + UpdatedAt: FormatTime(post.UpdatedAt), + Author: author, + IsLiked: isLiked, + IsFavorited: isFavorited, + } +} + +// ConvertPostToDetailResponse 将Post转换为PostDetailResponse +func ConvertPostToDetailResponse(post *model.Post, isLiked, isFavorited bool) *PostDetailResponse { + if post == nil { + return nil + } + + images := make([]PostImageResponse, 0) + for _, img := range post.Images { + images = append(images, ConvertPostImageToResponse(&img)) + } + + var author *UserResponse + if post.User != nil { + author = ConvertUserToResponse(post.User) + } + + return &PostDetailResponse{ + ID: post.ID, + UserID: post.UserID, + Title: post.Title, + Content: post.Content, + Images: images, + Status: string(post.Status), + LikesCount: post.LikesCount, + CommentsCount: post.CommentsCount, + FavoritesCount: post.FavoritesCount, + SharesCount: post.SharesCount, + ViewsCount: post.ViewsCount, + IsPinned: post.IsPinned, + IsLocked: post.IsLocked, + IsVote: post.IsVote, + CreatedAt: FormatTime(post.CreatedAt), + UpdatedAt: FormatTime(post.UpdatedAt), + Author: author, + IsLiked: isLiked, + IsFavorited: isFavorited, + } +} + +// ConvertPostsToResponse 将Post列表转换为响应列表(每个帖子独立检查点赞/收藏状态) +func ConvertPostsToResponse(posts []*model.Post, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse { + result := make([]*PostResponse, 0, len(posts)) + for _, post := range posts { + isLiked := false + isFavorited := false + if isLikedMap != nil { + isLiked = isLikedMap[post.ID] + } + if isFavoritedMap != nil { + isFavorited = isFavoritedMap[post.ID] + } + result = append(result, ConvertPostToResponse(post, isLiked, isFavorited)) + } + return result +} + +// BuildPostResponse 构建单个帖子响应(包含交互状态) +// 这是一个语义化的辅助函数,便于 Handler 层调用 +func BuildPostResponse(post *model.Post, isLiked, isFavorited bool) *PostResponse { + return ConvertPostToResponse(post, isLiked, isFavorited) +} + +// BuildPostsWithInteractionResponse 批量构建帖子响应(包含交互状态) +// 这是一个语义化的辅助函数,便于 Handler 层调用 +func BuildPostsWithInteractionResponse(posts []*model.Post, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse { + return ConvertPostsToResponse(posts, isLikedMap, isFavoritedMap) +} + +// ==================== Comment 转换 ==================== + +// ConvertCommentToResponse 将Comment转换为CommentResponse +func ConvertCommentToResponse(comment *model.Comment, isLiked bool) *CommentResponse { + if comment == nil { + return nil + } + + var author *UserResponse + if comment.User != nil { + author = ConvertUserToResponse(comment.User) + } + + // 转换子回复(扁平化结构) + var replies []*CommentResponse + if len(comment.Replies) > 0 { + replies = make([]*CommentResponse, 0, len(comment.Replies)) + for _, reply := range comment.Replies { + replies = append(replies, ConvertCommentToResponse(reply, false)) + } + } + + // TargetID 就是 ParentID,前端根据这个 ID 找到被回复用户的昵称 + var targetID *string + if comment.ParentID != nil && *comment.ParentID != "" { + targetID = comment.ParentID + } + + // 解析图片JSON + var images []CommentImageResponse + if comment.Images != "" { + var urlList []string + if err := json.Unmarshal([]byte(comment.Images), &urlList); err == nil { + images = make([]CommentImageResponse, 0, len(urlList)) + for _, url := range urlList { + images = append(images, CommentImageResponse{URL: url}) + } + } + } + + return &CommentResponse{ + ID: comment.ID, + PostID: comment.PostID, + UserID: comment.UserID, + ParentID: comment.ParentID, + RootID: comment.RootID, + Content: comment.Content, + Images: images, + LikesCount: comment.LikesCount, + RepliesCount: comment.RepliesCount, + CreatedAt: FormatTime(comment.CreatedAt), + Author: author, + IsLiked: isLiked, + TargetID: targetID, + Replies: replies, + } +} + +// ConvertCommentsToResponse 将Comment列表转换为响应列表 +func ConvertCommentsToResponse(comments []*model.Comment, isLiked bool) []*CommentResponse { + result := make([]*CommentResponse, 0, len(comments)) + for _, comment := range comments { + result = append(result, ConvertCommentToResponse(comment, isLiked)) + } + return result +} + +// IsLikedChecker 点赞状态检查器接口 +type IsLikedChecker interface { + IsLiked(ctx context.Context, commentID, userID string) bool +} + +// ConvertCommentToResponseWithUser 将Comment转换为CommentResponse(根据用户ID检查点赞状态) +func ConvertCommentToResponseWithUser(comment *model.Comment, userID string, checker IsLikedChecker) *CommentResponse { + if comment == nil { + return nil + } + + // 检查当前用户是否点赞了该评论 + isLiked := false + if userID != "" && checker != nil { + isLiked = checker.IsLiked(context.Background(), comment.ID, userID) + } + + var author *UserResponse + if comment.User != nil { + author = ConvertUserToResponse(comment.User) + } + + // 转换子回复(扁平化结构),递归检查点赞状态 + var replies []*CommentResponse + if len(comment.Replies) > 0 { + replies = make([]*CommentResponse, 0, len(comment.Replies)) + for _, reply := range comment.Replies { + replies = append(replies, ConvertCommentToResponseWithUser(reply, userID, checker)) + } + } + + // TargetID 就是 ParentID,前端根据这个 ID 找到被回复用户的昵称 + var targetID *string + if comment.ParentID != nil && *comment.ParentID != "" { + targetID = comment.ParentID + } + + // 解析图片JSON + var images []CommentImageResponse + if comment.Images != "" { + var urlList []string + if err := json.Unmarshal([]byte(comment.Images), &urlList); err == nil { + images = make([]CommentImageResponse, 0, len(urlList)) + for _, url := range urlList { + images = append(images, CommentImageResponse{URL: url}) + } + } + } + + return &CommentResponse{ + ID: comment.ID, + PostID: comment.PostID, + UserID: comment.UserID, + ParentID: comment.ParentID, + RootID: comment.RootID, + Content: comment.Content, + Images: images, + LikesCount: comment.LikesCount, + RepliesCount: comment.RepliesCount, + CreatedAt: FormatTime(comment.CreatedAt), + Author: author, + IsLiked: isLiked, + TargetID: targetID, + Replies: replies, + } +} + +// ConvertCommentsToResponseWithUser 将Comment列表转换为响应列表(根据用户ID检查点赞状态) +func ConvertCommentsToResponseWithUser(comments []*model.Comment, userID string, checker IsLikedChecker) []*CommentResponse { + result := make([]*CommentResponse, 0, len(comments)) + for _, comment := range comments { + result = append(result, ConvertCommentToResponseWithUser(comment, userID, checker)) + } + return result +} diff --git a/internal/dto/user_converter.go b/internal/dto/user_converter.go new file mode 100644 index 0000000..4778607 --- /dev/null +++ b/internal/dto/user_converter.go @@ -0,0 +1,235 @@ +package dto + +import ( + "carrot_bbs/internal/model" + "carrot_bbs/internal/pkg/utils" +) + +// ==================== User 转换 ==================== + +// getAvatarOrDefault 获取头像URL,如果为空则返回在线头像生成服务的URL +func getAvatarOrDefault(user *model.User) string { + return utils.GetAvatarOrDefault(user.Username, user.Nickname, user.Avatar) +} + +// ConvertUserToResponse 将User转换为UserResponse +func ConvertUserToResponse(user *model.User) *UserResponse { + if user == nil { + return nil + } + return &UserResponse{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Email: user.Email, + Phone: user.Phone, + EmailVerified: user.EmailVerified, + Avatar: getAvatarOrDefault(user), + CoverURL: user.CoverURL, + Bio: user.Bio, + Website: user.Website, + Location: user.Location, + PostsCount: user.PostsCount, + FollowersCount: user.FollowersCount, + FollowingCount: user.FollowingCount, + CreatedAt: FormatTime(user.CreatedAt), + } +} + +// ConvertUserToResponseWithFollowing 将User转换为UserResponse(包含关注状态) +func ConvertUserToResponseWithFollowing(user *model.User, isFollowing bool) *UserResponse { + if user == nil { + return nil + } + return &UserResponse{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Email: user.Email, + Phone: user.Phone, + EmailVerified: user.EmailVerified, + Avatar: getAvatarOrDefault(user), + CoverURL: user.CoverURL, + Bio: user.Bio, + Website: user.Website, + Location: user.Location, + PostsCount: user.PostsCount, + FollowersCount: user.FollowersCount, + FollowingCount: user.FollowingCount, + IsFollowing: isFollowing, + IsFollowingMe: false, // 默认false,需要单独计算 + CreatedAt: FormatTime(user.CreatedAt), + } +} + +// ConvertUserToResponseWithPostsCount 将User转换为UserResponse(使用实时计算的帖子数量) +func ConvertUserToResponseWithPostsCount(user *model.User, postsCount int) *UserResponse { + if user == nil { + return nil + } + return &UserResponse{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Email: user.Email, + Phone: user.Phone, + EmailVerified: user.EmailVerified, + Avatar: getAvatarOrDefault(user), + CoverURL: user.CoverURL, + Bio: user.Bio, + Website: user.Website, + Location: user.Location, + PostsCount: postsCount, + FollowersCount: user.FollowersCount, + FollowingCount: user.FollowingCount, + CreatedAt: FormatTime(user.CreatedAt), + } +} + +// ConvertUserToResponseWithMutualFollow 将User转换为UserResponse(包含双向关注状态) +func ConvertUserToResponseWithMutualFollow(user *model.User, isFollowing, isFollowingMe bool) *UserResponse { + if user == nil { + return nil + } + return &UserResponse{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Email: user.Email, + Phone: user.Phone, + EmailVerified: user.EmailVerified, + Avatar: getAvatarOrDefault(user), + CoverURL: user.CoverURL, + Bio: user.Bio, + Website: user.Website, + Location: user.Location, + PostsCount: user.PostsCount, + FollowersCount: user.FollowersCount, + FollowingCount: user.FollowingCount, + IsFollowing: isFollowing, + IsFollowingMe: isFollowingMe, + CreatedAt: FormatTime(user.CreatedAt), + } +} + +// ConvertUserToResponseWithMutualFollowAndPostsCount 将User转换为UserResponse(包含双向关注状态和实时计算的帖子数量) +func ConvertUserToResponseWithMutualFollowAndPostsCount(user *model.User, isFollowing, isFollowingMe bool, postsCount int) *UserResponse { + if user == nil { + return nil + } + return &UserResponse{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Email: user.Email, + Phone: user.Phone, + EmailVerified: user.EmailVerified, + Avatar: getAvatarOrDefault(user), + CoverURL: user.CoverURL, + Bio: user.Bio, + Website: user.Website, + Location: user.Location, + PostsCount: postsCount, + FollowersCount: user.FollowersCount, + FollowingCount: user.FollowingCount, + IsFollowing: isFollowing, + IsFollowingMe: isFollowingMe, + CreatedAt: FormatTime(user.CreatedAt), + } +} + +// ConvertUserToDetailResponse 将User转换为UserDetailResponse +func ConvertUserToDetailResponse(user *model.User) *UserDetailResponse { + if user == nil { + return nil + } + return &UserDetailResponse{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Email: user.Email, + EmailVerified: user.EmailVerified, + Avatar: getAvatarOrDefault(user), + CoverURL: user.CoverURL, + Bio: user.Bio, + Website: user.Website, + Location: user.Location, + PostsCount: user.PostsCount, + FollowersCount: user.FollowersCount, + FollowingCount: user.FollowingCount, + IsVerified: user.IsVerified, + CreatedAt: FormatTime(user.CreatedAt), + } +} + +// ConvertUserToDetailResponseWithPostsCount 将User转换为UserDetailResponse(使用实时计算的帖子数量) +func ConvertUserToDetailResponseWithPostsCount(user *model.User, postsCount int) *UserDetailResponse { + if user == nil { + return nil + } + return &UserDetailResponse{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Email: user.Email, + EmailVerified: user.EmailVerified, + Phone: user.Phone, // 仅当前用户自己可见 + Avatar: getAvatarOrDefault(user), + CoverURL: user.CoverURL, + Bio: user.Bio, + Website: user.Website, + Location: user.Location, + PostsCount: postsCount, + FollowersCount: user.FollowersCount, + FollowingCount: user.FollowingCount, + IsVerified: user.IsVerified, + CreatedAt: FormatTime(user.CreatedAt), + } +} + +// ConvertUsersToResponse 将User列表转换为响应列表 +func ConvertUsersToResponse(users []*model.User) []*UserResponse { + result := make([]*UserResponse, 0, len(users)) + for _, user := range users { + result = append(result, ConvertUserToResponse(user)) + } + return result +} + +// ConvertUsersToResponseWithMutualFollow 将User列表转换为响应列表(包含双向关注状态) +// followingStatusMap: key是用户ID,value是[isFollowing, isFollowingMe] +func ConvertUsersToResponseWithMutualFollow(users []*model.User, followingStatusMap map[string][2]bool) []*UserResponse { + result := make([]*UserResponse, 0, len(users)) + for _, user := range users { + status, ok := followingStatusMap[user.ID] + if ok { + result = append(result, ConvertUserToResponseWithMutualFollow(user, status[0], status[1])) + } else { + result = append(result, ConvertUserToResponse(user)) + } + } + return result +} + +// ConvertUsersToResponseWithMutualFollowAndPostsCount 将User列表转换为响应列表(包含双向关注状态和实时计算的帖子数量) +// followingStatusMap: key是用户ID,value是[isFollowing, isFollowingMe] +// postsCountMap: key是用户ID,value是帖子数量 +func ConvertUsersToResponseWithMutualFollowAndPostsCount(users []*model.User, followingStatusMap map[string][2]bool, postsCountMap map[string]int64) []*UserResponse { + result := make([]*UserResponse, 0, len(users)) + for _, user := range users { + status, hasStatus := followingStatusMap[user.ID] + postsCount, hasPostsCount := postsCountMap[user.ID] + + // 如果没有帖子数量,使用数据库中的值 + if !hasPostsCount { + postsCount = int64(user.PostsCount) + } + + if hasStatus { + result = append(result, ConvertUserToResponseWithMutualFollowAndPostsCount(user, status[0], status[1], int(postsCount))) + } else { + result = append(result, ConvertUserToResponseWithPostsCount(user, int(postsCount))) + } + } + return result +} diff --git a/internal/errors/app_errors.go b/internal/errors/app_errors.go new file mode 100644 index 0000000..80b48e8 --- /dev/null +++ b/internal/errors/app_errors.go @@ -0,0 +1,121 @@ +package errors + +import ( + "errors" + "fmt" +) + +// AppError 统一的应用错误类型 +type AppError struct { + Code string + Message string + Cause error +} + +func (e *AppError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Cause) + } + return fmt.Sprintf("[%s] %s", e.Code, e.Message) +} + +func (e *AppError) Unwrap() error { + return e.Cause +} + +// 预定义错误 +var ( + // 通用错误 + ErrNotFound = &AppError{Code: "NOT_FOUND", Message: "资源不存在"} + ErrUnauthorized = &AppError{Code: "UNAUTHORIZED", Message: "未授权"} + ErrForbidden = &AppError{Code: "FORBIDDEN", Message: "禁止访问"} + ErrBadRequest = &AppError{Code: "BAD_REQUEST", Message: "请求参数错误"} + ErrInternal = &AppError{Code: "INTERNAL_ERROR", Message: "内部服务器错误"} + + // 用户相关错误 + ErrUserNotFound = &AppError{Code: "USER_NOT_FOUND", Message: "用户不存在"} + ErrUserAlreadyExists = &AppError{Code: "USER_ALREADY_EXISTS", Message: "用户已存在"} + ErrInvalidCredentials = &AppError{Code: "INVALID_CREDENTIALS", Message: "用户名或密码错误"} + ErrInvalidUsername = &AppError{Code: "INVALID_USERNAME", Message: "无效的用户名"} + ErrInvalidEmail = &AppError{Code: "INVALID_EMAIL", Message: "无效的邮箱"} + ErrInvalidPhone = &AppError{Code: "INVALID_PHONE", Message: "无效的手机号"} + ErrWeakPassword = &AppError{Code: "WEAK_PASSWORD", Message: "密码强度不足"} + ErrUsernameExists = &AppError{Code: "USERNAME_EXISTS", Message: "用户名已存在"} + ErrEmailExists = &AppError{Code: "EMAIL_EXISTS", Message: "邮箱已存在"} + ErrPhoneExists = &AppError{Code: "PHONE_EXISTS", Message: "手机号已存在"} + ErrUserBanned = &AppError{Code: "USER_BANNED", Message: "用户已被封禁"} + ErrUserBlocked = &AppError{Code: "USER_BLOCKED", Message: "存在屏蔽关系"} + ErrInvalidOperation = &AppError{Code: "INVALID_OPERATION", Message: "无效操作"} + ErrEmailServiceUnavailable = &AppError{Code: "EMAIL_SERVICE_UNAVAILABLE", Message: "邮件服务不可用"} + ErrVerificationCodeTooFrequent = &AppError{Code: "VERIFICATION_CODE_TOO_FREQUENT", Message: "验证码发送过于频繁"} + ErrVerificationCodeInvalid = &AppError{Code: "VERIFICATION_CODE_INVALID", Message: "验证码无效"} + ErrVerificationCodeExpired = &AppError{Code: "VERIFICATION_CODE_EXPIRED", Message: "验证码已过期"} + ErrVerificationCodeUnavailable = &AppError{Code: "VERIFICATION_CODE_UNAVAILABLE", Message: "验证码存储不可用"} + ErrEmailAlreadyVerified = &AppError{Code: "EMAIL_ALREADY_VERIFIED", Message: "邮箱已验证"} + ErrEmailNotBound = &AppError{Code: "EMAIL_NOT_BOUND", Message: "邮箱未绑定"} + + // 帖子相关错误 + ErrPostNotFound = &AppError{Code: "POST_NOT_FOUND", Message: "帖子不存在"} + ErrPostDeleted = &AppError{Code: "POST_DELETED", Message: "帖子已删除"} + + // 群组相关错误 + ErrGroupNotFound = &AppError{Code: "GROUP_NOT_FOUND", Message: "群组不存在"} + ErrNotGroupMember = &AppError{Code: "NOT_GROUP_MEMBER", Message: "不是群组成员"} + ErrAlreadyGroupMember = &AppError{Code: "ALREADY_GROUP_MEMBER", Message: "已是群组成员"} + ErrNotGroupAdmin = &AppError{Code: "NOT_GROUP_ADMIN", Message: "不是群管理员"} + ErrNotGroupOwner = &AppError{Code: "NOT_GROUP_OWNER", Message: "不是群主"} + ErrGroupFull = &AppError{Code: "GROUP_FULL", Message: "群已满"} + ErrCannotRemoveOwner = &AppError{Code: "CANNOT_REMOVE_OWNER", Message: "不能移除群主"} + ErrCannotMuteOwner = &AppError{Code: "CANNOT_MUTE_OWNER", Message: "不能禁言群主"} + ErrMuted = &AppError{Code: "MUTED", Message: "你已被禁言"} + ErrMuteAllEnabled = &AppError{Code: "MUTE_ALL_ENABLED", Message: "全员禁言中"} + ErrCannotJoin = &AppError{Code: "CANNOT_JOIN", Message: "该群不允许加入"} + ErrJoinRequestPending = &AppError{Code: "JOIN_REQUEST_PENDING", Message: "加群申请已提交"} + ErrGroupRequestNotFound = &AppError{Code: "GROUP_REQUEST_NOT_FOUND", Message: "加群请求不存在"} + ErrGroupRequestHandled = &AppError{Code: "GROUP_REQUEST_HANDLED", Message: "该加群请求已处理"} + ErrNotRequestTarget = &AppError{Code: "NOT_REQUEST_TARGET", Message: "不是邀请目标用户"} + ErrNoEligibleInvitee = &AppError{Code: "NO_ELIGIBLE_INVITEE", Message: "没有可邀请的用户"} + ErrNotMutualFollow = &AppError{Code: "NOT_MUTUAL_FOLLOW", Message: "仅支持邀请互相关注用户"} + ErrInviteNotAccepted = &AppError{Code: "INVITE_NOT_ACCEPTED", Message: "被邀请人尚未同意邀请,无法审批"} + + // 表情包相关错误 + ErrStickerAlreadyExists = &AppError{Code: "STICKER_ALREADY_EXISTS", Message: "表情包已存在"} + ErrInvalidStickerURL = &AppError{Code: "INVALID_STICKER_URL", Message: "无效的表情包URL"} + + // 日程相关错误 + ErrInvalidSchedulePayload = &AppError{Code: "INVALID_SCHEDULE_PAYLOAD", Message: "无效的日程数据"} + ErrScheduleCourseNotFound = &AppError{Code: "SCHEDULE_COURSE_NOT_FOUND", Message: "日程课程不存在"} + ErrScheduleForbidden = &AppError{Code: "SCHEDULE_FORBIDDEN", Message: "禁止的日程操作"} + ErrScheduleColorDuplicated = &AppError{Code: "SCHEDULE_COLOR_DUPLICATED", Message: "课程颜色已被使用"} + + // 通知相关错误 + ErrUnauthorizedNotification = &AppError{Code: "UNAUTHORIZED_NOTIFICATION", Message: "无权删除此通知"} +) + +// Wrap 包装错误 +func Wrap(err error, appErr *AppError) *AppError { + return &AppError{ + Code: appErr.Code, + Message: appErr.Message, + Cause: err, + } +} + +// New 创建新错误 +func New(code, message string) *AppError { + return &AppError{Code: code, Message: message} +} + +// Is 判断是否为特定错误 +func Is(err, target error) bool { + return errors.Is(err, target) +} + +// As 将错误转换为 AppError +func As(err error) (*AppError, bool) { + var appErr *AppError + if errors.As(err, &appErr) { + return appErr, true + } + return nil, false +} diff --git a/internal/handler/gorse_handler.go b/internal/handler/gorse_handler.go index ec89118..132b8b1 100644 --- a/internal/handler/gorse_handler.go +++ b/internal/handler/gorse_handler.go @@ -11,21 +11,24 @@ import ( "carrot_bbs/internal/pkg/gorse" "carrot_bbs/internal/pkg/response" - gorseio "github.com/gorse-io/gorse-go" "github.com/gin-gonic/gin" + gorseio "github.com/gorse-io/gorse-go" + "gorm.io/gorm" ) // GorseHandler Gorse推荐处理器 type GorseHandler struct { importPassword string gorseConfig config.GorseConfig + db *gorm.DB } // NewGorseHandler 创建Gorse处理器 -func NewGorseHandler(cfg config.GorseConfig) *GorseHandler { +func NewGorseHandler(cfg config.GorseConfig, db *gorm.DB) *GorseHandler { return &GorseHandler{ importPassword: cfg.ImportPassword, gorseConfig: cfg, + db: db, } } @@ -98,7 +101,7 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) { // 导入帖子 var posts []model.Post - if err := model.DB.Find(&posts).Error; err != nil { + if err := h.db.Find(&posts).Error; err != nil { return nil, err } for _, post := range posts { @@ -126,7 +129,7 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) { // 导入用户 var users []model.User - if err := model.DB.Find(&users).Error; err != nil { + if err := h.db.Find(&users).Error; err != nil { return nil, err } for _, user := range users { @@ -148,7 +151,7 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) { // 导入点赞 var likes []model.PostLike - if err := model.DB.Find(&likes).Error; err != nil { + if err := h.db.Find(&likes).Error; err != nil { return nil, err } for _, like := range likes { @@ -167,7 +170,7 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) { // 导入收藏 var favorites []model.Favorite - if err := model.DB.Find(&favorites).Error; err != nil { + if err := h.db.Find(&favorites).Error; err != nil { return nil, err } for _, fav := range favorites { @@ -186,7 +189,7 @@ func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) { // 导入评论(按用户-帖子去重) var comments []model.Comment - if err := model.DB.Where("status = ?", model.CommentStatusPublished).Find(&comments).Error; err != nil { + if err := h.db.Where("status = ?", model.CommentStatusPublished).Find(&comments).Error; err != nil { return nil, err } seen := make(map[string]struct{}) @@ -231,4 +234,4 @@ func buildPostCategories(post *model.Post) []string { categories = append(categories, "this_week") } return categories -} \ No newline at end of file +} diff --git a/internal/handler/group_handler.go b/internal/handler/group_handler.go index 0e8cd1f..e40e811 100644 --- a/internal/handler/group_handler.go +++ b/internal/handler/group_handler.go @@ -15,11 +15,11 @@ import ( // GroupHandler 群组处理器 type GroupHandler struct { groupService service.GroupService - userService *service.UserService + userService service.UserService } // NewGroupHandler 创建群组处理器 -func NewGroupHandler(groupService service.GroupService, userService *service.UserService) *GroupHandler { +func NewGroupHandler(groupService service.GroupService, userService service.UserService) *GroupHandler { return &GroupHandler{ groupService: groupService, userService: userService, diff --git a/internal/handler/message_handler.go b/internal/handler/message_handler.go index 9fb59ac..0fcdfc2 100644 --- a/internal/handler/message_handler.go +++ b/internal/handler/message_handler.go @@ -20,13 +20,13 @@ import ( type MessageHandler struct { chatService service.ChatService messageService *service.MessageService - userService *service.UserService + userService service.UserService groupService service.GroupService sseHub *sse.Hub } // NewMessageHandler 创建消息处理器 -func NewMessageHandler(chatService service.ChatService, messageService *service.MessageService, userService *service.UserService, groupService service.GroupService, sseHub *sse.Hub) *MessageHandler { +func NewMessageHandler(chatService service.ChatService, messageService *service.MessageService, userService service.UserService, groupService service.GroupService, sseHub *sse.Hub) *MessageHandler { return &MessageHandler{ chatService: chatService, messageService: messageService, diff --git a/internal/handler/post_handler.go b/internal/handler/post_handler.go index c6a419f..73a523e 100644 --- a/internal/handler/post_handler.go +++ b/internal/handler/post_handler.go @@ -15,12 +15,12 @@ import ( // PostHandler 帖子处理器 type PostHandler struct { - postService *service.PostService - userService *service.UserService + postService service.PostService + userService service.UserService } // NewPostHandler 创建帖子处理器 -func NewPostHandler(postService *service.PostService, userService *service.UserService) *PostHandler { +func NewPostHandler(postService service.PostService, userService service.UserService) *PostHandler { return &PostHandler{ postService: postService, userService: userService, diff --git a/internal/handler/system_message_handler.go b/internal/handler/system_message_handler.go index 7c9b82c..44f1989 100644 --- a/internal/handler/system_message_handler.go +++ b/internal/handler/system_message_handler.go @@ -4,6 +4,7 @@ import ( "strconv" "carrot_bbs/internal/cache" + "github.com/gin-gonic/gin" "carrot_bbs/internal/dto" @@ -16,16 +17,19 @@ import ( type SystemMessageHandler struct { systemMsgService service.SystemMessageService notifyRepo *repository.SystemNotificationRepository + cache cache.Cache } // NewSystemMessageHandler 创建系统消息处理器 func NewSystemMessageHandler( systemMsgService service.SystemMessageService, notifyRepo *repository.SystemNotificationRepository, + cacheBackend cache.Cache, ) *SystemMessageHandler { return &SystemMessageHandler{ systemMsgService: systemMsgService, notifyRepo: notifyRepo, + cache: cacheBackend, } } @@ -101,7 +105,7 @@ func (h *SystemMessageHandler) MarkAsRead(c *gin.Context) { response.InternalServerError(c, "failed to mark as read") return } - cache.InvalidateUnreadSystem(cache.GetCache(), userID) + cache.InvalidateUnreadSystem(h.cache, userID) response.SuccessWithMessage(c, "marked as read", nil) } @@ -121,7 +125,7 @@ func (h *SystemMessageHandler) MarkAllAsRead(c *gin.Context) { response.InternalServerError(c, "failed to mark all as read") return } - cache.InvalidateUnreadSystem(cache.GetCache(), userID) + cache.InvalidateUnreadSystem(h.cache, userID) response.SuccessWithMessage(c, "all messages marked as read", nil) } @@ -148,7 +152,7 @@ func (h *SystemMessageHandler) DeleteSystemMessage(c *gin.Context) { response.InternalServerError(c, "failed to delete notification") return } - cache.InvalidateUnreadSystem(cache.GetCache(), userID) + cache.InvalidateUnreadSystem(h.cache, userID) response.SuccessWithMessage(c, "notification deleted", nil) } diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index 2c5e886..a80565a 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -12,12 +12,12 @@ import ( // UserHandler 用户处理器 type UserHandler struct { - userService *service.UserService + userService service.UserService jwtService *service.JWTService } // NewUserHandler 创建用户处理器 -func NewUserHandler(userService *service.UserService) *UserHandler { +func NewUserHandler(userService service.UserService) *UserHandler { return &UserHandler{ userService: userService, } diff --git a/internal/handler/vote_handler.go b/internal/handler/vote_handler.go index 5a19bb3..121aa57 100644 --- a/internal/handler/vote_handler.go +++ b/internal/handler/vote_handler.go @@ -14,11 +14,11 @@ import ( // VoteHandler 投票处理器 type VoteHandler struct { voteService *service.VoteService - postService *service.PostService + postService service.PostService } // NewVoteHandler 创建投票处理器 -func NewVoteHandler(voteService *service.VoteService, postService *service.PostService) *VoteHandler { +func NewVoteHandler(voteService *service.VoteService, postService service.PostService) *VoteHandler { return &VoteHandler{ voteService: voteService, postService: postService, diff --git a/internal/model/init.go b/internal/model/init.go index 4e81504..03007ac 100644 --- a/internal/model/init.go +++ b/internal/model/init.go @@ -14,11 +14,8 @@ import ( "carrot_bbs/internal/config" ) -// DB 全局数据库连接 -var DB *gorm.DB - -// InitDB 初始化数据库连接 -func InitDB(cfg *config.DatabaseConfig) error { +// NewDB 创建数据库连接(用于 Wire 依赖注入) +func NewDB(cfg *config.DatabaseConfig) (*gorm.DB, error) { var err error var db *gorm.DB gormLogger := logger.New( @@ -52,28 +49,26 @@ func InitDB(cfg *config.DatabaseConfig) error { } if err != nil { - return fmt.Errorf("failed to connect to database: %w", err) + return nil, fmt.Errorf("failed to connect to database: %w", err) } - DB = db - // 配置连接池(SQLite不支持连接池配置,跳过) if cfg.Type != "sqlite" { - sqlDB, err := DB.DB() + sqlDB, err := db.DB() if err != nil { - return fmt.Errorf("failed to get database instance: %w", err) + return nil, fmt.Errorf("failed to get database instance: %w", err) } sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) } // 自动迁移 - if err := autoMigrate(DB); err != nil { - return fmt.Errorf("failed to auto migrate: %w", err) + if err := autoMigrate(db); err != nil { + return nil, fmt.Errorf("failed to auto migrate: %w", err) } log.Printf("Database connected (%s) and migrated successfully", cfg.Type) - return nil + return db, nil } func parseGormLogLevel(level string) logger.LogLevel { @@ -155,8 +150,8 @@ func autoMigrate(db *gorm.DB) error { } // CloseDB 关闭数据库连接 -func CloseDB() { - if sqlDB, err := DB.DB(); err == nil { +func CloseDB(db *gorm.DB) { + if sqlDB, err := db.DB(); err == nil { sqlDB.Close() } } diff --git a/internal/pkg/response/response.go b/internal/pkg/response/response.go index adc049b..7be7275 100644 --- a/internal/pkg/response/response.go +++ b/internal/pkg/response/response.go @@ -5,7 +5,7 @@ import ( "github.com/gin-gonic/gin" - "carrot_bbs/internal/service" + apperrors "carrot_bbs/internal/errors" ) // Response 统一响应结构 @@ -119,44 +119,72 @@ func Paginated(c *gin.Context, list interface{}, total int64, page, pageSize int } // HandleServiceError 统一处理 Service 错误 -// 如果 err 是 *service.ServiceError,返回对应的业务错误码和消息 +// 如果 err 是 *apperrors.AppError,返回对应的业务错误码和消息 // 如果 err 是其他错误,返回 false(调用方应处理通用错误) // 返回 true 表示错误已处理,false 表示需要调用方继续处理 func HandleServiceError(c *gin.Context, err error) bool { if err == nil { return false } - if se, ok := err.(*service.ServiceError); ok { - ErrorWithStatusCode(c, statusCodeFromCode(se.Code), se.Code, se.Message) + if se, ok := apperrors.As(err); ok { + statusCode := statusCodeFromAppErrorCode(se.Code) + ErrorWithStringCode(c, statusCode, se.Code, se.Message) return true } return false } // HandleError 统一处理错误(带默认消息) -// 如果 err 是 *service.ServiceError,返回对应的业务错误码和消息 +// 如果 err 是 *apperrors.AppError,返回对应的业务错误码和消息 // 如果 err 是其他错误,返回 InternalServerError 并使用 defaultMessage func HandleError(c *gin.Context, err error, defaultMessage string) { if err == nil { return } - if se, ok := err.(*service.ServiceError); ok { - ErrorWithStatusCode(c, statusCodeFromCode(se.Code), se.Code, se.Message) + if se, ok := apperrors.As(err); ok { + statusCode := statusCodeFromAppErrorCode(se.Code) + ErrorWithStringCode(c, statusCode, se.Code, se.Message) return } InternalServerError(c, defaultMessage) } -// statusCodeFromCode 根据业务错误码获取 HTTP 状态码 -func statusCodeFromCode(code int) int { - switch { - case code >= 200 && code < 300: - return http.StatusOK - case code >= 400 && code < 500: - return code - case code >= 500: - return code +// statusCodeFromAppErrorCode 根据AppError的错误码字符串获取 HTTP 状态码 +func statusCodeFromAppErrorCode(code string) int { + switch code { + case "NOT_FOUND", "USER_NOT_FOUND", "POST_NOT_FOUND", "GROUP_NOT_FOUND", + "GROUP_REQUEST_NOT_FOUND", "SCHEDULE_COURSE_NOT_FOUND": + return http.StatusNotFound + case "UNAUTHORIZED", "INVALID_CREDENTIALS": + return http.StatusUnauthorized + case "FORBIDDEN", "NOT_GROUP_MEMBER", "NOT_GROUP_ADMIN", "NOT_GROUP_OWNER", + "USER_BANNED", "USER_BLOCKED", "MUTED", "MUTE_ALL_ENABLED", + "SCHEDULE_FORBIDDEN", "UNAUTHORIZED_NOTIFICATION": + return http.StatusForbidden + case "BAD_REQUEST", "INVALID_USERNAME", "INVALID_EMAIL", "INVALID_PHONE", + "WEAK_PASSWORD", "USERNAME_EXISTS", "EMAIL_EXISTS", "PHONE_EXISTS", + "USER_ALREADY_EXISTS", "INVALID_OPERATION", "VERIFICATION_CODE_INVALID", + "VERIFICATION_CODE_EXPIRED", "EMAIL_ALREADY_VERIFIED", "EMAIL_NOT_BOUND", + "GROUP_FULL", "ALREADY_GROUP_MEMBER", "CANNOT_REMOVE_OWNER", "CANNOT_MUTE_OWNER", + "CANNOT_JOIN", "JOIN_REQUEST_PENDING", "GROUP_REQUEST_HANDLED", + "NOT_REQUEST_TARGET", "NO_ELIGIBLE_INVITEE", "NOT_MUTUAL_FOLLOW", + "INVITE_NOT_ACCEPTED", "STICKER_ALREADY_EXISTS", "INVALID_STICKER_URL", + "INVALID_SCHEDULE_PAYLOAD", "SCHEDULE_COLOR_DUPLICATED": + return http.StatusBadRequest + case "VERIFICATION_CODE_TOO_FREQUENT": + return http.StatusTooManyRequests + case "EMAIL_SERVICE_UNAVAILABLE", "VERIFICATION_CODE_UNAVAILABLE", "INTERNAL_ERROR": + return http.StatusInternalServerError default: return http.StatusBadRequest } } + +// ErrorWithStringCode 带字符串错误码的错误响应 +func ErrorWithStringCode(c *gin.Context, statusCode int, code string, message string) { + c.JSON(statusCode, ResponseSnakeCase{ + Code: 0, // 为了兼容,这里设为0,实际错误码在message中体现 + Message: message, + Data: code, + }) +} diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index f141d02..d0781c6 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -2,6 +2,7 @@ package repository import ( "carrot_bbs/internal/model" + "context" "time" "gorm.io/gorm" @@ -17,6 +18,14 @@ func NewPostRepository(db *gorm.DB) *PostRepository { return &PostRepository{db: db} } +// getDB 获取数据库连接(优先使用 context 中的事务) +func (r *PostRepository) getDB(ctx context.Context) *gorm.DB { + if tx := GetTxFromContext(ctx); tx != nil { + return tx + } + return r.db +} + // Create 创建帖子 func (r *PostRepository) Create(post *model.Post, images []string) error { return r.db.Transaction(func(tx *gorm.DB) error { @@ -419,3 +428,104 @@ func (r *PostRepository) GetByIDs(ids []string) ([]*model.Post, error) { return ordered, nil } + +// ========== Context-aware methods for transaction support ========== + +// CreateWithContext 创建帖子(支持事务) +func (r *PostRepository) CreateWithContext(ctx context.Context, post *model.Post, images []string) error { + db := r.getDB(ctx) + + // 如果 context 中有事务,直接使用 + if tx := GetTxFromContext(ctx); tx != nil { + return r.createWithTx(tx, post, images) + } + + // 否则开启新事务 + return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.createWithTx(tx, post, images) + }) +} + +// createWithTx 使用指定事务创建帖子 +func (r *PostRepository) createWithTx(tx *gorm.DB, post *model.Post, images []string) error { + // 创建帖子 + if err := tx.Create(post).Error; err != nil { + return err + } + + // 创建图片记录 + for i, url := range images { + image := &model.PostImage{ + PostID: post.ID, + URL: url, + SortOrder: i, + } + if err := tx.Create(image).Error; err != nil { + return err + } + } + + return nil +} + +// GetByIDWithContext 根据ID获取帖子(支持事务) +func (r *PostRepository) GetByIDWithContext(ctx context.Context, id string) (*model.Post, error) { + var post model.Post + err := r.getDB(ctx).WithContext(ctx).Preload("User").Preload("Images").First(&post, "id = ?", id).Error + if err != nil { + return nil, err + } + return &post, nil +} + +// UpdateWithContext 更新帖子(支持事务) +func (r *PostRepository) UpdateWithContext(ctx context.Context, post *model.Post) error { + post.UpdatedAt = time.Now() + return r.getDB(ctx).WithContext(ctx).Save(post).Error +} + +// DeleteWithContext 删除帖子(支持事务) +func (r *PostRepository) DeleteWithContext(ctx context.Context, id string) error { + db := r.getDB(ctx) + + // 如果 context 中有事务,直接使用 + if tx := GetTxFromContext(ctx); tx != nil { + return r.deleteWithTx(tx, id) + } + + // 否则开启新事务 + return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.deleteWithTx(tx, id) + }) +} + +// deleteWithTx 使用指定事务删除帖子 +func (r *PostRepository) deleteWithTx(tx *gorm.DB, id string) error { + // 删除帖子图片 + if err := tx.Where("post_id = ?", id).Delete(&model.PostImage{}).Error; err != nil { + return err + } + + // 删除帖子点赞记录 + if err := tx.Where("post_id = ?", id).Delete(&model.PostLike{}).Error; err != nil { + return err + } + + // 删除帖子收藏记录 + if err := tx.Where("post_id = ?", id).Delete(&model.Favorite{}).Error; err != nil { + return err + } + + // 删除评论点赞记录(子查询获取该帖子所有评论ID) + if err := tx.Where("comment_id IN (SELECT id FROM comments WHERE post_id = ?)", id).Delete(&model.CommentLike{}).Error; err != nil { + return err + } + + // 删除帖子评论 + if err := tx.Where("post_id = ?", id).Delete(&model.Comment{}).Error; err != nil { + return err + } + + // 最后删除帖子本身(软删除) + return tx.Delete(&model.Post{}, "id = ?", id).Error +} diff --git a/internal/repository/transaction.go b/internal/repository/transaction.go new file mode 100644 index 0000000..df7d9d4 --- /dev/null +++ b/internal/repository/transaction.go @@ -0,0 +1,43 @@ +package repository + +import ( + "context" + + "gorm.io/gorm" +) + +// TransactionManager 事务管理器接口 +type TransactionManager interface { + // RunInTransaction 在事务中执行函数 + RunInTransaction(ctx context.Context, fn func(ctx context.Context) error) error +} + +// gormTransactionManager GORM 事务管理器实现 +type gormTransactionManager struct { + db *gorm.DB +} + +// NewTransactionManager 创建事务管理器 +func NewTransactionManager(db *gorm.DB) TransactionManager { + return &gormTransactionManager{db: db} +} + +// RunInTransaction 在事务中执行函数 +func (m *gormTransactionManager) RunInTransaction(ctx context.Context, fn func(ctx context.Context) error) error { + return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 将事务 TX 存入 context + ctxWithTx := context.WithValue(ctx, txKey{}, tx) + return fn(ctxWithTx) + }) +} + +// txKey 事务 context key +type txKey struct{} + +// GetTxFromContext 从 context 获取事务 TX +func GetTxFromContext(ctx context.Context) *gorm.DB { + if tx, ok := ctx.Value(txKey{}).(*gorm.DB); ok { + return tx + } + return nil +} diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index 380b0a3..83cc4f3 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -72,6 +72,7 @@ func NewChatService( userRepo *repository.UserRepository, sensitive SensitiveService, sseHub *sse.Hub, + cacheBackend cache.Cache, ) ChatService { // 创建适配器 convRepoAdapter := cache.NewConversationRepositoryAdapter(repo) @@ -79,7 +80,7 @@ func NewChatService( // 创建会话缓存 conversationCache := cache.NewConversationCache( - cache.GetCache(), + cacheBackend, convRepoAdapter, msgRepoAdapter, cache.DefaultConversationCacheSettings(), diff --git a/internal/service/comment_service.go b/internal/service/comment_service.go index 0d5ebf3..272e020 100644 --- a/internal/service/comment_service.go +++ b/internal/service/comment_service.go @@ -24,12 +24,12 @@ type CommentService struct { } // NewCommentService 创建评论服务 -func NewCommentService(commentRepo *repository.CommentRepository, postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService) *CommentService { +func NewCommentService(commentRepo *repository.CommentRepository, postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService, cacheBackend cache.Cache) *CommentService { return &CommentService{ commentRepo: commentRepo, postRepo: postRepo, systemMessageService: systemMessageService, - cache: cache.GetCache(), + cache: cacheBackend, gorseClient: gorseClient, postAIService: postAIService, } diff --git a/internal/service/email_code_service.go b/internal/service/email_code_service.go index 87d0a01..ff75bf6 100644 --- a/internal/service/email_code_service.go +++ b/internal/service/email_code_service.go @@ -43,9 +43,6 @@ type emailCodeServiceImpl struct { } func NewEmailCodeService(emailService EmailService, cacheBackend cache.Cache) EmailCodeService { - if cacheBackend == nil { - cacheBackend = cache.GetCache() - } return &emailCodeServiceImpl{ emailService: emailService, cache: cacheBackend, diff --git a/internal/service/group_service.go b/internal/service/group_service.go index 6aa0117..bf89d22 100644 --- a/internal/service/group_service.go +++ b/internal/service/group_service.go @@ -8,6 +8,7 @@ import ( "time" "carrot_bbs/internal/cache" + apperrors "carrot_bbs/internal/errors" "carrot_bbs/internal/model" "carrot_bbs/internal/pkg/sse" "carrot_bbs/internal/pkg/utils" @@ -23,25 +24,26 @@ const ( GroupCacheJitter = 0.1 ) -// 群组服务错误定义 +// 群组服务错误定义 - 使用统一的 AppError var ( - ErrGroupNotFound = &ServiceError{Code: 404, Message: "群组不存在"} - ErrNotGroupMember = &ServiceError{Code: 403, Message: "不是群成员"} - ErrNotGroupAdmin = &ServiceError{Code: 403, Message: "不是群管理员"} - ErrNotGroupOwner = &ServiceError{Code: 403, Message: "不是群主"} - ErrGroupFull = &ServiceError{Code: 400, Message: "群已满"} - ErrAlreadyMember = &ServiceError{Code: 400, Message: "已经是群成员"} - ErrCannotRemoveOwner = &ServiceError{Code: 400, Message: "不能移除群主"} - ErrCannotMuteOwner = &ServiceError{Code: 400, Message: "不能禁言群主"} - ErrMuted = &ServiceError{Code: 403, Message: "你已被禁言"} - ErrMuteAllEnabled = &ServiceError{Code: 403, Message: "全员禁言中"} - ErrCannotJoin = &ServiceError{Code: 400, Message: "该群不允许加入"} - ErrJoinRequestPending = &ServiceError{Code: 400, Message: "加群申请已提交"} - ErrGroupRequestNotFound = &ServiceError{Code: 404, Message: "加群请求不存在"} - ErrGroupRequestHandled = &ServiceError{Code: 400, Message: "该加群请求已处理"} - ErrNotRequestTarget = &ServiceError{Code: 400, Message: "不是邀请目标用户"} - ErrNoEligibleInvitee = &ServiceError{Code: 400, Message: "没有可邀请的用户"} - ErrNotMutualFollow = &ServiceError{Code: 400, Message: "仅支持邀请互相关注用户"} + ErrGroupNotFound = apperrors.ErrGroupNotFound + ErrNotGroupMember = apperrors.ErrNotGroupMember + ErrNotGroupAdmin = apperrors.ErrNotGroupAdmin + ErrNotGroupOwner = apperrors.ErrNotGroupOwner + ErrGroupFull = apperrors.ErrGroupFull + ErrAlreadyMember = apperrors.ErrAlreadyGroupMember + ErrCannotRemoveOwner = apperrors.ErrCannotRemoveOwner + ErrCannotMuteOwner = apperrors.ErrCannotMuteOwner + ErrMuted = apperrors.ErrMuted + ErrMuteAllEnabled = apperrors.ErrMuteAllEnabled + ErrCannotJoin = apperrors.ErrCannotJoin + ErrJoinRequestPending = apperrors.ErrJoinRequestPending + ErrGroupRequestNotFound = apperrors.ErrGroupRequestNotFound + ErrGroupRequestHandled = apperrors.ErrGroupRequestHandled + ErrNotRequestTarget = apperrors.ErrNotRequestTarget + ErrNoEligibleInvitee = apperrors.ErrNoEligibleInvitee + ErrNotMutualFollow = apperrors.ErrNotMutualFollow + ErrInviteNotAccepted = apperrors.ErrInviteNotAccepted ) // GroupService 群组服务接口 @@ -104,7 +106,7 @@ type groupService struct { } // NewGroupService 创建群组服务 -func NewGroupService(db *gorm.DB, groupRepo repository.GroupRepository, userRepo *repository.UserRepository, messageRepo *repository.MessageRepository, sseHub *sse.Hub) GroupService { +func NewGroupService(db *gorm.DB, groupRepo repository.GroupRepository, userRepo *repository.UserRepository, messageRepo *repository.MessageRepository, sseHub *sse.Hub, cacheBackend cache.Cache) GroupService { return &groupService{ db: db, groupRepo: groupRepo, @@ -113,7 +115,7 @@ func NewGroupService(db *gorm.DB, groupRepo repository.GroupRepository, userRepo requestRepo: repository.NewGroupJoinRequestRepository(db), notifyRepo: repository.NewSystemNotificationRepository(db), sseHub: sseHub, - cache: cache.GetCache(), + cache: cacheBackend, } } @@ -937,7 +939,7 @@ func (s *groupService) SetGroupAddRequest(userID string, flag string, approve bo if req.RequestType == model.GroupJoinRequestTypeInvite { if req.ReviewerID == "" { // 被邀请人还未同意,管理员不能提前审批 - return &ServiceError{Code: 400, Message: "被邀请人尚未同意邀请,无法审批"} + return ErrInviteNotAccepted } if req.ReviewerID != req.TargetUserID { // ReviewerID 不是被邀请人,说明已经被其他人处理过 diff --git a/internal/service/message_service.go b/internal/service/message_service.go index ed0f772..b0a5acc 100644 --- a/internal/service/message_service.go +++ b/internal/service/message_service.go @@ -37,14 +37,14 @@ type MessageService struct { } // NewMessageService 创建消息服务 -func NewMessageService(db *gorm.DB, messageRepo *repository.MessageRepository) *MessageService { +func NewMessageService(db *gorm.DB, messageRepo *repository.MessageRepository, cacheBackend cache.Cache) *MessageService { // 创建适配器 convRepoAdapter := cache.NewConversationRepositoryAdapter(messageRepo) msgRepoAdapter := cache.NewMessageRepositoryAdapter(messageRepo) // 创建会话缓存 conversationCache := cache.NewConversationCache( - cache.GetCache(), + cacheBackend, convRepoAdapter, msgRepoAdapter, cache.DefaultConversationCacheSettings(), @@ -54,7 +54,7 @@ func NewMessageService(db *gorm.DB, messageRepo *repository.MessageRepository) * db: db, messageRepo: messageRepo, conversationCache: conversationCache, - baseCache: cache.GetCache(), + baseCache: cacheBackend, } } diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index e44ba1a..30a6f3b 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -5,6 +5,7 @@ import ( "time" "carrot_bbs/internal/cache" + apperrors "carrot_bbs/internal/errors" "carrot_bbs/internal/model" "carrot_bbs/internal/repository" ) @@ -23,10 +24,10 @@ type NotificationService struct { } // NewNotificationService 创建通知服务 -func NewNotificationService(notificationRepo *repository.NotificationRepository) *NotificationService { +func NewNotificationService(notificationRepo *repository.NotificationRepository, cacheBackend cache.Cache) *NotificationService { return &NotificationService{ notificationRepo: notificationRepo, - cache: cache.GetCache(), + cache: cacheBackend, } } @@ -166,4 +167,4 @@ func (s *NotificationService) ClearAllNotifications(ctx context.Context, userID } // 错误定义 -var ErrUnauthorizedNotification = &ServiceError{Code: 403, Message: "unauthorized to delete this notification"} +var ErrUnauthorizedNotification = apperrors.ErrUnauthorizedNotification diff --git a/internal/service/post_service.go b/internal/service/post_service.go index 09cf309..e7b7414 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -23,23 +23,63 @@ const ( anonymousViewUserID = "_anon_view" ) -// PostService 帖子服务 -type PostService struct { +// PostService 帖子服务接口 +type PostService interface { + // 帖子CRUD + Create(ctx context.Context, userID, title, content string, images []string) (*model.Post, error) + GetByID(ctx context.Context, id string) (*model.Post, error) + Update(ctx context.Context, post *model.Post) error + UpdateWithImages(ctx context.Context, post *model.Post, images *[]string) error + Delete(ctx context.Context, id string) error + + // 帖子列表 + List(ctx context.Context, page, pageSize int, userID string, includePending bool) ([]*model.Post, int64, error) + GetLatestPosts(ctx context.Context, page, pageSize int, userID string) ([]*model.Post, int64, error) + GetLatestPostsForOwner(ctx context.Context, page, pageSize int, userID string) ([]*model.Post, int64, error) + GetUserPosts(ctx context.Context, userID string, page, pageSize int, includePending bool) ([]*model.Post, int64, error) + GetFavorites(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) + Search(ctx context.Context, keyword string, page, pageSize int) ([]*model.Post, int64, error) + + // 关注和推荐 + GetFollowingPosts(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) + GetHotPosts(ctx context.Context, page, pageSize int) ([]*model.Post, int64, error) + GetRecommendedPosts(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) + + // 交互功能 + Like(ctx context.Context, postID, userID string) error + Unlike(ctx context.Context, postID, userID string) error + IsLiked(ctx context.Context, postID, userID string) bool + Favorite(ctx context.Context, postID, userID string) error + Unfavorite(ctx context.Context, postID, userID string) error + IsFavorited(ctx context.Context, postID, userID string) bool + + // 交互状态查询 + GetPostInteractionStatus(ctx context.Context, postIDs []string, userID string) (map[string]bool, map[string]bool, error) + GetPostInteractionStatusSingle(ctx context.Context, postID, userID string) (isLiked, isFavorited bool) + + // 其他 + IncrementViews(ctx context.Context, postID, userID string) error +} + +// postServiceImpl 帖子服务实现 +type postServiceImpl struct { postRepo *repository.PostRepository systemMessageService SystemMessageService cache cache.Cache gorseClient gorse.Client postAIService *PostAIService + txManager repository.TransactionManager // 事务管理器 } // NewPostService 创建帖子服务 -func NewPostService(postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService) *PostService { - return &PostService{ +func NewPostService(postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService, cacheBackend cache.Cache, txManager repository.TransactionManager) PostService { + return &postServiceImpl{ postRepo: postRepo, systemMessageService: systemMessageService, - cache: cache.GetCache(), + cache: cacheBackend, gorseClient: gorseClient, postAIService: postAIService, + txManager: txManager, } } @@ -50,7 +90,7 @@ type PostListResult struct { } // Create 创建帖子 -func (s *PostService) Create(ctx context.Context, userID, title, content string, images []string) (*model.Post, error) { +func (s *postServiceImpl) Create(ctx context.Context, userID, title, content string, images []string) (*model.Post, error) { post := &model.Post{ UserID: userID, Title: title, @@ -73,7 +113,7 @@ func (s *PostService) Create(ctx context.Context, userID, title, content string, return s.postRepo.GetByID(post.ID) } -func (s *PostService) reviewPostAsync(postID, userID, title, content string, images []string) { +func (s *postServiceImpl) reviewPostAsync(postID, userID, title, content string, images []string) { defer func() { if r := recover(); r != nil { log.Printf("[ERROR] Panic in post moderation async flow, fallback publish post=%s panic=%v", postID, r) @@ -139,7 +179,7 @@ func (s *PostService) reviewPostAsync(postID, userID, title, content string, ima } } -func (s *PostService) updateModerationStatusWithRetry(postID string, status model.PostStatus, rejectReason string, reviewedBy string) error { +func (s *postServiceImpl) updateModerationStatusWithRetry(postID string, status model.PostStatus, rejectReason string, reviewedBy string) error { const maxAttempts = 3 const retryDelay = 200 * time.Millisecond @@ -159,12 +199,12 @@ func (s *PostService) updateModerationStatusWithRetry(postID string, status mode return lastErr } -func (s *PostService) invalidatePostCaches(postID string) { +func (s *postServiceImpl) invalidatePostCaches(postID string) { cache.InvalidatePostDetail(s.cache, postID) cache.InvalidatePostList(s.cache) } -func (s *PostService) notifyModerationRejected(userID, reason string) { +func (s *postServiceImpl) notifyModerationRejected(userID, reason string) { if s.systemMessageService == nil || strings.TrimSpace(userID) == "" { return } @@ -187,17 +227,17 @@ func (s *PostService) notifyModerationRejected(userID, reason string) { } // GetByID 根据ID获取帖子 -func (s *PostService) GetByID(ctx context.Context, id string) (*model.Post, error) { +func (s *postServiceImpl) GetByID(ctx context.Context, id string) (*model.Post, error) { return s.postRepo.GetByID(id) } // Update 更新帖子 -func (s *PostService) Update(ctx context.Context, post *model.Post) error { +func (s *postServiceImpl) Update(ctx context.Context, post *model.Post) error { return s.UpdateWithImages(ctx, post, nil) } // UpdateWithImages 更新帖子并可选更新图片(images=nil 表示不更新图片) -func (s *PostService) UpdateWithImages(ctx context.Context, post *model.Post, images *[]string) error { +func (s *postServiceImpl) UpdateWithImages(ctx context.Context, post *model.Post, images *[]string) error { err := s.postRepo.UpdateWithImages(post, images) if err != nil { return err @@ -211,7 +251,7 @@ func (s *PostService) UpdateWithImages(ctx context.Context, post *model.Post, im } // Delete 删除帖子 -func (s *PostService) Delete(ctx context.Context, id string) error { +func (s *postServiceImpl) Delete(ctx context.Context, id string) error { err := s.postRepo.Delete(id) if err != nil { return err @@ -234,7 +274,7 @@ func (s *PostService) Delete(ctx context.Context, id string) error { } // List 获取帖子列表(带缓存) -func (s *PostService) List(ctx context.Context, page, pageSize int, userID string, includePending bool) ([]*model.Post, int64, error) { +func (s *postServiceImpl) List(ctx context.Context, page, pageSize int, userID string, includePending bool) ([]*model.Post, int64, error) { cacheSettings := cache.GetSettings() postListTTL := cacheSettings.PostListTTL if postListTTL <= 0 { @@ -299,22 +339,22 @@ func (s *PostService) List(ctx context.Context, page, pageSize int, userID strin } // GetLatestPosts 获取最新帖子(语义化别名) -func (s *PostService) GetLatestPosts(ctx context.Context, page, pageSize int, userID string) ([]*model.Post, int64, error) { +func (s *postServiceImpl) GetLatestPosts(ctx context.Context, page, pageSize int, userID string) ([]*model.Post, int64, error) { return s.List(ctx, page, pageSize, userID, false) } // GetLatestPostsForOwner 获取作者视角帖子列表(包含待审核) -func (s *PostService) GetLatestPostsForOwner(ctx context.Context, page, pageSize int, userID string) ([]*model.Post, int64, error) { +func (s *postServiceImpl) GetLatestPostsForOwner(ctx context.Context, page, pageSize int, userID string) ([]*model.Post, int64, error) { return s.List(ctx, page, pageSize, userID, true) } // GetUserPosts 获取用户帖子 -func (s *PostService) GetUserPosts(ctx context.Context, userID string, page, pageSize int, includePending bool) ([]*model.Post, int64, error) { +func (s *postServiceImpl) GetUserPosts(ctx context.Context, userID string, page, pageSize int, includePending bool) ([]*model.Post, int64, error) { return s.postRepo.GetUserPosts(userID, page, pageSize, includePending) } // Like 点赞 -func (s *PostService) Like(ctx context.Context, postID, userID string) error { +func (s *postServiceImpl) Like(ctx context.Context, postID, userID string) error { // 获取帖子信息用于发送通知 post, err := s.postRepo.GetByID(postID) if err != nil { @@ -352,7 +392,7 @@ func (s *PostService) Like(ctx context.Context, postID, userID string) error { } // Unlike 取消点赞 -func (s *PostService) Unlike(ctx context.Context, postID, userID string) error { +func (s *postServiceImpl) Unlike(ctx context.Context, postID, userID string) error { err := s.postRepo.Unlike(postID, userID) if err != nil { return err @@ -374,12 +414,12 @@ func (s *PostService) Unlike(ctx context.Context, postID, userID string) error { } // IsLiked 检查是否点赞 -func (s *PostService) IsLiked(ctx context.Context, postID, userID string) bool { +func (s *postServiceImpl) IsLiked(ctx context.Context, postID, userID string) bool { return s.postRepo.IsLiked(postID, userID) } // Favorite 收藏 -func (s *PostService) Favorite(ctx context.Context, postID, userID string) error { +func (s *postServiceImpl) Favorite(ctx context.Context, postID, userID string) error { // 获取帖子信息用于发送通知 post, err := s.postRepo.GetByID(postID) if err != nil { @@ -417,7 +457,7 @@ func (s *PostService) Favorite(ctx context.Context, postID, userID string) error } // Unfavorite 取消收藏 -func (s *PostService) Unfavorite(ctx context.Context, postID, userID string) error { +func (s *postServiceImpl) Unfavorite(ctx context.Context, postID, userID string) error { err := s.postRepo.Unfavorite(postID, userID) if err != nil { return err @@ -439,12 +479,12 @@ func (s *PostService) Unfavorite(ctx context.Context, postID, userID string) err } // IsFavorited 检查是否收藏 -func (s *PostService) IsFavorited(ctx context.Context, postID, userID string) bool { +func (s *postServiceImpl) IsFavorited(ctx context.Context, postID, userID string) bool { return s.postRepo.IsFavorited(postID, userID) } // GetPostInteractionStatus 批量获取帖子的交互状态(点赞、收藏) -func (s *PostService) GetPostInteractionStatus(ctx context.Context, postIDs []string, userID string) (map[string]bool, map[string]bool, error) { +func (s *postServiceImpl) GetPostInteractionStatus(ctx context.Context, postIDs []string, userID string) (map[string]bool, map[string]bool, error) { isLikedMap := make(map[string]bool) isFavoritedMap := make(map[string]bool) @@ -461,7 +501,7 @@ func (s *PostService) GetPostInteractionStatus(ctx context.Context, postIDs []st } // GetPostInteractionStatusSingle 获取单个帖子的交互状态 -func (s *PostService) GetPostInteractionStatusSingle(ctx context.Context, postID, userID string) (isLiked, isFavorited bool) { +func (s *postServiceImpl) GetPostInteractionStatusSingle(ctx context.Context, postID, userID string) (isLiked, isFavorited bool) { if userID == "" { return false, false } @@ -469,7 +509,7 @@ func (s *PostService) GetPostInteractionStatusSingle(ctx context.Context, postID } // IncrementViews 增加帖子观看量并同步到Gorse -func (s *PostService) IncrementViews(ctx context.Context, postID, userID string) error { +func (s *postServiceImpl) IncrementViews(ctx context.Context, postID, userID string) error { if err := s.postRepo.IncrementViews(postID); err != nil { return err } @@ -494,17 +534,17 @@ func (s *PostService) IncrementViews(ctx context.Context, postID, userID string) } // GetFavorites 获取收藏列表 -func (s *PostService) GetFavorites(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) { +func (s *postServiceImpl) GetFavorites(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) { return s.postRepo.GetFavorites(userID, page, pageSize) } // Search 搜索帖子 -func (s *PostService) Search(ctx context.Context, keyword string, page, pageSize int) ([]*model.Post, int64, error) { +func (s *postServiceImpl) Search(ctx context.Context, keyword string, page, pageSize int) ([]*model.Post, int64, error) { return s.postRepo.Search(keyword, page, pageSize) } // GetFollowingPosts 获取关注用户的帖子(带缓存) -func (s *PostService) GetFollowingPosts(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) { +func (s *postServiceImpl) GetFollowingPosts(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) { cacheSettings := cache.GetSettings() postListTTL := cacheSettings.PostListTTL if postListTTL <= 0 { @@ -546,7 +586,7 @@ func (s *PostService) GetFollowingPosts(ctx context.Context, userID string, page } // GetHotPosts 获取热门帖子(使用Gorse非个性化推荐) -func (s *PostService) GetHotPosts(ctx context.Context, page, pageSize int) ([]*model.Post, int64, error) { +func (s *postServiceImpl) GetHotPosts(ctx context.Context, page, pageSize int) ([]*model.Post, int64, error) { // 如果Gorse启用,使用自定义的非个性化推荐器 if s.gorseClient.IsEnabled() { offset := (page - 1) * pageSize @@ -580,7 +620,7 @@ func (s *PostService) GetHotPosts(ctx context.Context, page, pageSize int) ([]*m } // getHotPostsFromDB 从数据库获取热门帖子(降级路径) -func (s *PostService) getHotPostsFromDB(ctx context.Context, page, pageSize int) ([]*model.Post, int64, error) { +func (s *postServiceImpl) getHotPostsFromDB(ctx context.Context, page, pageSize int) ([]*model.Post, int64, error) { // 直接查询数据库,不再使用本地缓存(Gorse失败降级时使用) posts, total, err := s.postRepo.GetHotPosts(page, pageSize) if err != nil { @@ -590,7 +630,7 @@ func (s *PostService) getHotPostsFromDB(ctx context.Context, page, pageSize int) } // GetRecommendedPosts 获取推荐帖子 -func (s *PostService) GetRecommendedPosts(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) { +func (s *postServiceImpl) GetRecommendedPosts(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) { // 如果Gorse未启用或用户未登录,降级为热门帖子 if !s.gorseClient.IsEnabled() || userID == "" { return s.GetHotPosts(ctx, page, pageSize) @@ -638,7 +678,7 @@ func (s *PostService) GetRecommendedPosts(ctx context.Context, userID string, pa } // buildPostCategories 构建帖子的类别标签 -func (s *PostService) buildPostCategories(post *model.Post) []string { +func (s *postServiceImpl) buildPostCategories(post *model.Post) []string { var categories []string // 热度标签 @@ -676,3 +716,17 @@ func (s *PostService) buildPostCategories(post *model.Post) []string { return categories } + +// ========== 事务管理器示例方法 ========== + +// DeletePostWithTransaction 使用事务管理器删除帖子(示例) +// 此方法展示如何在 Service 层使用事务管理器控制跨多个 Repository 的事务 +// 当需要在一个事务中执行多个 Repository 操作时,可以使用此模式 +func (s *postServiceImpl) DeletePostWithTransaction(ctx context.Context, postID string) error { + // 使用事务管理器执行事务 + return s.txManager.RunInTransaction(ctx, func(ctx context.Context) error { + // 在同一个事务中执行删除操作 + // Repository 方法会通过 context 获取事务 TX + return s.postRepo.DeleteWithContext(ctx, postID) + }) +} diff --git a/internal/service/schedule_service.go b/internal/service/schedule_service.go index 4f5e44c..039d2a0 100644 --- a/internal/service/schedule_service.go +++ b/internal/service/schedule_service.go @@ -8,6 +8,7 @@ import ( "strings" "carrot_bbs/internal/dto" + apperrors "carrot_bbs/internal/errors" "carrot_bbs/internal/model" "carrot_bbs/internal/repository" @@ -15,10 +16,10 @@ import ( ) var ( - ErrInvalidSchedulePayload = &ServiceError{Code: 400, Message: "invalid schedule payload"} - ErrScheduleCourseNotFound = &ServiceError{Code: 404, Message: "schedule course not found"} - ErrScheduleForbidden = &ServiceError{Code: 403, Message: "forbidden schedule operation"} - ErrScheduleColorDuplicated = &ServiceError{Code: 400, Message: "course color already used"} + ErrInvalidSchedulePayload = apperrors.ErrInvalidSchedulePayload + ErrScheduleCourseNotFound = apperrors.ErrScheduleCourseNotFound + ErrScheduleForbidden = apperrors.ErrScheduleForbidden + ErrScheduleColorDuplicated = apperrors.ErrScheduleColorDuplicated ) var hexColorRegex = regexp.MustCompile(`^#[0-9A-F]{6}$`) diff --git a/internal/service/sticker_service.go b/internal/service/sticker_service.go index 4dc5f90..bc5e0ac 100644 --- a/internal/service/sticker_service.go +++ b/internal/service/sticker_service.go @@ -1,16 +1,18 @@ package service import ( - "carrot_bbs/internal/model" - "carrot_bbs/internal/repository" "errors" "net/url" "strings" + + apperrors "carrot_bbs/internal/errors" + "carrot_bbs/internal/model" + "carrot_bbs/internal/repository" ) var ( - ErrStickerAlreadyExists = &ServiceError{Code: 400, Message: "sticker already exists"} - ErrInvalidStickerURL = &ServiceError{Code: 400, Message: "invalid sticker url"} + ErrStickerAlreadyExists = apperrors.ErrStickerAlreadyExists + ErrInvalidStickerURL = apperrors.ErrInvalidStickerURL ) // StickerService 自定义表情服务接口 diff --git a/internal/service/system_message_service.go b/internal/service/system_message_service.go index 4f28946..a9fd040 100644 --- a/internal/service/system_message_service.go +++ b/internal/service/system_message_service.go @@ -42,13 +42,14 @@ func NewSystemMessageService( pushService PushService, userRepo *repository.UserRepository, postRepo *repository.PostRepository, + cacheBackend cache.Cache, ) SystemMessageService { return &systemMessageServiceImpl{ notifyRepo: notifyRepo, pushService: pushService, userRepo: userRepo, postRepo: postRepo, - cache: cache.GetCache(), + cache: cacheBackend, } } diff --git a/internal/service/upload_service.go b/internal/service/upload_service.go index 01b0598..616a867 100644 --- a/internal/service/upload_service.go +++ b/internal/service/upload_service.go @@ -16,6 +16,7 @@ import ( "strings" "carrot_bbs/internal/pkg/s3" + _ "golang.org/x/image/bmp" _ "golang.org/x/image/tiff" ) @@ -23,11 +24,11 @@ import ( // UploadService 上传服务 type UploadService struct { s3Client *s3.Client - userService *UserService + userService UserService } // NewUploadService 创建上传服务 -func NewUploadService(s3Client *s3.Client, userService *UserService) *UploadService { +func NewUploadService(s3Client *s3.Client, userService UserService) *UploadService { return &UploadService{ s3Client: s3Client, userService: userService, diff --git a/internal/service/user_service.go b/internal/service/user_service.go index 3891230..69c13e9 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -6,13 +6,59 @@ import ( "strings" "carrot_bbs/internal/cache" + apperrors "carrot_bbs/internal/errors" "carrot_bbs/internal/model" "carrot_bbs/internal/pkg/utils" "carrot_bbs/internal/repository" ) -// UserService 用户服务 -type UserService struct { +// UserService 用户服务接口 +type UserService interface { + // 验证码相关 + SendRegisterCode(ctx context.Context, email string) error + SendPasswordResetCode(ctx context.Context, email string) error + SendCurrentUserEmailVerifyCode(ctx context.Context, userID, email string) error + SendChangePasswordCode(ctx context.Context, userID string) error + + // 邮箱验证 + VerifyCurrentUserEmail(ctx context.Context, userID, email, verificationCode string) error + + // 用户认证 + Register(ctx context.Context, username, email, password, nickname, phone, verificationCode string) (*model.User, error) + Login(ctx context.Context, account, password string) (*model.User, error) + + // 用户查询 + GetUserByID(ctx context.Context, id string) (*model.User, error) + GetUserPostCount(ctx context.Context, userID string) (int64, error) + GetUserPostCountBatch(ctx context.Context, userIDs []string) (map[string]int64, error) + GetUserByIDWithFollowingStatus(ctx context.Context, userID, currentUserID string) (*model.User, bool, error) + GetUserByIDWithMutualFollowStatus(ctx context.Context, userID, currentUserID string) (*model.User, bool, bool, error) + Search(ctx context.Context, keyword string, page, pageSize int) ([]*model.User, int64, error) + CheckUsernameAvailable(ctx context.Context, username string) (bool, error) + + // 用户更新 + UpdateUser(ctx context.Context, user *model.User) error + ChangePassword(ctx context.Context, userID, oldPassword, newPassword, verificationCode string) error + ResetPasswordByEmail(ctx context.Context, email, verificationCode, newPassword string) error + + // 关注相关 + GetFollowers(ctx context.Context, userID string, page, pageSize int) ([]*model.User, int64, error) + GetFollowing(ctx context.Context, userID string, page, pageSize int) ([]*model.User, int64, error) + GetFollowingList(ctx context.Context, userID, page, pageSize string) ([]*model.User, error) + GetFollowersList(ctx context.Context, userID, page, pageSize string) ([]*model.User, error) + FollowUser(ctx context.Context, followerID, followeeID string) error + UnfollowUser(ctx context.Context, followerID, followeeID string) error + GetMutualFollowStatus(ctx context.Context, currentUserID string, targetUserIDs []string) (map[string][2]bool, error) + + // 黑名单相关 + BlockUser(ctx context.Context, blockerID, blockedID string) error + UnblockUser(ctx context.Context, blockerID, blockedID string) error + GetBlockedUsers(ctx context.Context, blockerID string, page, pageSize int) ([]*model.User, int64, error) + IsBlocked(ctx context.Context, blockerID, blockedID string) (bool, error) +} + +// userServiceImpl 用户服务实现 +type userServiceImpl struct { userRepo *repository.UserRepository systemMessageService SystemMessageService emailCodeService EmailCodeService @@ -24,8 +70,8 @@ func NewUserService( systemMessageService SystemMessageService, emailService EmailService, cacheBackend cache.Cache, -) *UserService { - return &UserService{ +) UserService { + return &userServiceImpl{ userRepo: userRepo, systemMessageService: systemMessageService, emailCodeService: NewEmailCodeService(emailService, cacheBackend), @@ -33,7 +79,7 @@ func NewUserService( } // SendRegisterCode 发送注册验证码 -func (s *UserService) SendRegisterCode(ctx context.Context, email string) error { +func (s *userServiceImpl) SendRegisterCode(ctx context.Context, email string) error { user, err := s.userRepo.GetByEmail(email) if err == nil && user != nil { return ErrEmailExists @@ -42,7 +88,7 @@ func (s *UserService) SendRegisterCode(ctx context.Context, email string) error } // SendPasswordResetCode 发送找回密码验证码 -func (s *UserService) SendPasswordResetCode(ctx context.Context, email string) error { +func (s *userServiceImpl) SendPasswordResetCode(ctx context.Context, email string) error { user, err := s.userRepo.GetByEmail(email) if err != nil || user == nil { return ErrUserNotFound @@ -51,7 +97,7 @@ func (s *UserService) SendPasswordResetCode(ctx context.Context, email string) e } // SendCurrentUserEmailVerifyCode 发送当前用户邮箱验证验证码 -func (s *UserService) SendCurrentUserEmailVerifyCode(ctx context.Context, userID, email string) error { +func (s *userServiceImpl) SendCurrentUserEmailVerifyCode(ctx context.Context, userID, email string) error { user, err := s.userRepo.GetByID(userID) if err != nil || user == nil { return ErrUserNotFound @@ -78,7 +124,7 @@ func (s *UserService) SendCurrentUserEmailVerifyCode(ctx context.Context, userID } // VerifyCurrentUserEmail 验证当前用户邮箱 -func (s *UserService) VerifyCurrentUserEmail(ctx context.Context, userID, email, verificationCode string) error { +func (s *userServiceImpl) VerifyCurrentUserEmail(ctx context.Context, userID, email, verificationCode string) error { user, err := s.userRepo.GetByID(userID) if err != nil || user == nil { return ErrUserNotFound @@ -107,7 +153,7 @@ func (s *UserService) VerifyCurrentUserEmail(ctx context.Context, userID, email, } // SendChangePasswordCode 发送修改密码验证码 -func (s *UserService) SendChangePasswordCode(ctx context.Context, userID string) error { +func (s *userServiceImpl) SendChangePasswordCode(ctx context.Context, userID string) error { user, err := s.userRepo.GetByID(userID) if err != nil || user == nil { return ErrUserNotFound @@ -119,7 +165,7 @@ func (s *UserService) SendChangePasswordCode(ctx context.Context, userID string) } // Register 用户注册 -func (s *UserService) Register(ctx context.Context, username, email, password, nickname, phone, verificationCode string) (*model.User, error) { +func (s *userServiceImpl) Register(ctx context.Context, username, email, password, nickname, phone, verificationCode string) (*model.User, error) { // 验证用户名 if !utils.ValidateUsername(username) { return nil, ErrInvalidUsername @@ -199,7 +245,7 @@ func (s *UserService) Register(ctx context.Context, username, email, password, n } // Login 用户登录 -func (s *UserService) Login(ctx context.Context, account, password string) (*model.User, error) { +func (s *userServiceImpl) Login(ctx context.Context, account, password string) (*model.User, error) { account = strings.TrimSpace(account) var ( user *model.User @@ -228,22 +274,22 @@ func (s *UserService) Login(ctx context.Context, account, password string) (*mod } // GetUserByID 根据ID获取用户 -func (s *UserService) GetUserByID(ctx context.Context, id string) (*model.User, error) { +func (s *userServiceImpl) GetUserByID(ctx context.Context, id string) (*model.User, error) { return s.userRepo.GetByID(id) } // GetUserPostCount 获取用户帖子数(实时计算) -func (s *UserService) GetUserPostCount(ctx context.Context, userID string) (int64, error) { +func (s *userServiceImpl) GetUserPostCount(ctx context.Context, userID string) (int64, error) { return s.userRepo.GetPostsCount(userID) } // GetUserPostCountBatch 批量获取用户帖子数(实时计算) -func (s *UserService) GetUserPostCountBatch(ctx context.Context, userIDs []string) (map[string]int64, error) { +func (s *userServiceImpl) GetUserPostCountBatch(ctx context.Context, userIDs []string) (map[string]int64, error) { return s.userRepo.GetPostsCountBatch(userIDs) } // GetUserByIDWithFollowingStatus 根据ID获取用户(包含当前用户是否关注的状态) -func (s *UserService) GetUserByIDWithFollowingStatus(ctx context.Context, userID, currentUserID string) (*model.User, bool, error) { +func (s *userServiceImpl) GetUserByIDWithFollowingStatus(ctx context.Context, userID, currentUserID string) (*model.User, bool, error) { user, err := s.userRepo.GetByID(userID) if err != nil { return nil, false, err @@ -263,7 +309,7 @@ func (s *UserService) GetUserByIDWithFollowingStatus(ctx context.Context, userID } // GetUserByIDWithMutualFollowStatus 根据ID获取用户(包含双向关注状态) -func (s *UserService) GetUserByIDWithMutualFollowStatus(ctx context.Context, userID, currentUserID string) (*model.User, bool, bool, error) { +func (s *userServiceImpl) GetUserByIDWithMutualFollowStatus(ctx context.Context, userID, currentUserID string) (*model.User, bool, bool, error) { user, err := s.userRepo.GetByID(userID) if err != nil { return nil, false, false, err @@ -290,22 +336,22 @@ func (s *UserService) GetUserByIDWithMutualFollowStatus(ctx context.Context, use } // UpdateUser 更新用户 -func (s *UserService) UpdateUser(ctx context.Context, user *model.User) error { +func (s *userServiceImpl) UpdateUser(ctx context.Context, user *model.User) error { return s.userRepo.Update(user) } // GetFollowers 获取粉丝 -func (s *UserService) GetFollowers(ctx context.Context, userID string, page, pageSize int) ([]*model.User, int64, error) { +func (s *userServiceImpl) GetFollowers(ctx context.Context, userID string, page, pageSize int) ([]*model.User, int64, error) { return s.userRepo.GetFollowers(userID, page, pageSize) } // GetFollowing 获取关注 -func (s *UserService) GetFollowing(ctx context.Context, userID string, page, pageSize int) ([]*model.User, int64, error) { +func (s *userServiceImpl) GetFollowing(ctx context.Context, userID string, page, pageSize int) ([]*model.User, int64, error) { return s.userRepo.GetFollowing(userID, page, pageSize) } // FollowUser 关注用户 -func (s *UserService) FollowUser(ctx context.Context, followerID, followeeID string) error { +func (s *userServiceImpl) FollowUser(ctx context.Context, followerID, followeeID string) error { blocked, err := s.userRepo.IsBlockedEitherDirection(followerID, followeeID) if err != nil { return err @@ -357,7 +403,7 @@ func (s *UserService) FollowUser(ctx context.Context, followerID, followeeID str } // UnfollowUser 取消关注用户 -func (s *UserService) UnfollowUser(ctx context.Context, followerID, followeeID string) error { +func (s *userServiceImpl) UnfollowUser(ctx context.Context, followerID, followeeID string) error { // 检查是否已经关注 isFollowing, err := s.userRepo.IsFollowing(followerID, followeeID) if err != nil { @@ -386,7 +432,7 @@ func (s *UserService) UnfollowUser(ctx context.Context, followerID, followeeID s } // BlockUser 拉黑用户,并自动清理双向关注/粉丝关系 -func (s *UserService) BlockUser(ctx context.Context, blockerID, blockedID string) error { +func (s *userServiceImpl) BlockUser(ctx context.Context, blockerID, blockedID string) error { if blockerID == blockedID { return ErrInvalidOperation } @@ -394,7 +440,7 @@ func (s *UserService) BlockUser(ctx context.Context, blockerID, blockedID string } // UnblockUser 取消拉黑 -func (s *UserService) UnblockUser(ctx context.Context, blockerID, blockedID string) error { +func (s *userServiceImpl) UnblockUser(ctx context.Context, blockerID, blockedID string) error { if blockerID == blockedID { return ErrInvalidOperation } @@ -402,17 +448,17 @@ func (s *UserService) UnblockUser(ctx context.Context, blockerID, blockedID stri } // GetBlockedUsers 获取黑名单列表 -func (s *UserService) GetBlockedUsers(ctx context.Context, blockerID string, page, pageSize int) ([]*model.User, int64, error) { +func (s *userServiceImpl) GetBlockedUsers(ctx context.Context, blockerID string, page, pageSize int) ([]*model.User, int64, error) { return s.userRepo.GetBlockedUsers(blockerID, page, pageSize) } // IsBlocked 检查当前用户是否已拉黑目标用户 -func (s *UserService) IsBlocked(ctx context.Context, blockerID, blockedID string) (bool, error) { +func (s *userServiceImpl) IsBlocked(ctx context.Context, blockerID, blockedID string) (bool, error) { return s.userRepo.IsBlocked(blockerID, blockedID) } // GetFollowingList 获取关注列表(字符串参数版本) -func (s *UserService) GetFollowingList(ctx context.Context, userID, page, pageSize string) ([]*model.User, error) { +func (s *userServiceImpl) GetFollowingList(ctx context.Context, userID, page, pageSize string) ([]*model.User, error) { // 转换字符串参数为整数 pageInt := 1 pageSizeInt := 20 @@ -434,7 +480,7 @@ func (s *UserService) GetFollowingList(ctx context.Context, userID, page, pageSi } // GetFollowersList 获取粉丝列表(字符串参数版本) -func (s *UserService) GetFollowersList(ctx context.Context, userID, page, pageSize string) ([]*model.User, error) { +func (s *userServiceImpl) GetFollowersList(ctx context.Context, userID, page, pageSize string) ([]*model.User, error) { // 转换字符串参数为整数 pageInt := 1 pageSizeInt := 20 @@ -456,12 +502,12 @@ func (s *UserService) GetFollowersList(ctx context.Context, userID, page, pageSi } // GetMutualFollowStatus 批量获取双向关注状态 -func (s *UserService) GetMutualFollowStatus(ctx context.Context, currentUserID string, targetUserIDs []string) (map[string][2]bool, error) { +func (s *userServiceImpl) GetMutualFollowStatus(ctx context.Context, currentUserID string, targetUserIDs []string) (map[string][2]bool, error) { return s.userRepo.GetMutualFollowStatus(currentUserID, targetUserIDs) } // CheckUsernameAvailable 检查用户名是否可用 -func (s *UserService) CheckUsernameAvailable(ctx context.Context, username string) (bool, error) { +func (s *userServiceImpl) CheckUsernameAvailable(ctx context.Context, username string) (bool, error) { user, err := s.userRepo.GetByUsername(username) if err != nil { return true, nil // 用户不存在,可用 @@ -470,7 +516,7 @@ func (s *UserService) CheckUsernameAvailable(ctx context.Context, username strin } // ChangePassword 修改密码 -func (s *UserService) ChangePassword(ctx context.Context, userID, oldPassword, newPassword, verificationCode string) error { +func (s *userServiceImpl) ChangePassword(ctx context.Context, userID, oldPassword, newPassword, verificationCode string) error { // 获取用户 user, err := s.userRepo.GetByID(userID) if err != nil { @@ -500,7 +546,7 @@ func (s *UserService) ChangePassword(ctx context.Context, userID, oldPassword, n } // ResetPasswordByEmail 通过邮箱重置密码 -func (s *UserService) ResetPasswordByEmail(ctx context.Context, email, verificationCode, newPassword string) error { +func (s *userServiceImpl) ResetPasswordByEmail(ctx context.Context, email, verificationCode, newPassword string) error { email = strings.TrimSpace(email) if !utils.ValidateEmail(email) { return ErrInvalidEmail @@ -526,40 +572,29 @@ func (s *UserService) ResetPasswordByEmail(ctx context.Context, email, verificat } // Search 搜索用户 -func (s *UserService) Search(ctx context.Context, keyword string, page, pageSize int) ([]*model.User, int64, error) { +func (s *userServiceImpl) Search(ctx context.Context, keyword string, page, pageSize int) ([]*model.User, int64, error) { return s.userRepo.Search(keyword, page, pageSize) } -// 错误定义 +// 错误定义 - 使用统一的 AppError var ( - ErrInvalidUsername = &ServiceError{Code: 400, Message: "invalid username"} - ErrInvalidEmail = &ServiceError{Code: 400, Message: "invalid email"} - ErrInvalidPhone = &ServiceError{Code: 400, Message: "invalid phone number"} - ErrWeakPassword = &ServiceError{Code: 400, Message: "password too weak"} - ErrUsernameExists = &ServiceError{Code: 400, Message: "username already exists"} - ErrEmailExists = &ServiceError{Code: 400, Message: "email already exists"} - ErrPhoneExists = &ServiceError{Code: 400, Message: "phone number already exists"} - ErrUserNotFound = &ServiceError{Code: 404, Message: "user not found"} - ErrUserBanned = &ServiceError{Code: 403, Message: "user is banned"} - ErrUserBlocked = &ServiceError{Code: 403, Message: "blocked relationship exists"} - ErrInvalidOperation = &ServiceError{Code: 400, Message: "invalid operation"} - ErrEmailServiceUnavailable = &ServiceError{Code: 503, Message: "email service unavailable"} - ErrVerificationCodeTooFrequent = &ServiceError{Code: 429, Message: "verification code sent too frequently"} - ErrVerificationCodeInvalid = &ServiceError{Code: 400, Message: "invalid verification code"} - ErrVerificationCodeExpired = &ServiceError{Code: 400, Message: "verification code expired"} - ErrVerificationCodeUnavailable = &ServiceError{Code: 500, Message: "verification code storage unavailable"} - ErrEmailAlreadyVerified = &ServiceError{Code: 400, Message: "email already verified"} - ErrEmailNotBound = &ServiceError{Code: 400, Message: "email not bound"} + ErrInvalidUsername = apperrors.ErrInvalidUsername + ErrInvalidEmail = apperrors.ErrInvalidEmail + ErrInvalidPhone = apperrors.ErrInvalidPhone + ErrWeakPassword = apperrors.ErrWeakPassword + ErrUsernameExists = apperrors.ErrUsernameExists + ErrEmailExists = apperrors.ErrEmailExists + ErrPhoneExists = apperrors.ErrPhoneExists + ErrUserNotFound = apperrors.ErrUserNotFound + ErrUserBanned = apperrors.ErrUserBanned + ErrUserBlocked = apperrors.ErrUserBlocked + ErrInvalidOperation = apperrors.ErrInvalidOperation + ErrEmailServiceUnavailable = apperrors.ErrEmailServiceUnavailable + ErrVerificationCodeTooFrequent = apperrors.ErrVerificationCodeTooFrequent + ErrVerificationCodeInvalid = apperrors.ErrVerificationCodeInvalid + ErrVerificationCodeExpired = apperrors.ErrVerificationCodeExpired + ErrVerificationCodeUnavailable = apperrors.ErrVerificationCodeUnavailable + ErrEmailAlreadyVerified = apperrors.ErrEmailAlreadyVerified + ErrEmailNotBound = apperrors.ErrEmailNotBound + ErrInvalidCredentials = apperrors.ErrInvalidCredentials ) - -// ServiceError 服务错误 -type ServiceError struct { - Code int - Message string -} - -func (e *ServiceError) Error() string { - return e.Message -} - -var ErrInvalidCredentials = &ServiceError{Code: 401, Message: "invalid username or password"} diff --git a/internal/wire/handler.go b/internal/wire/handler.go new file mode 100644 index 0000000..9616411 --- /dev/null +++ b/internal/wire/handler.go @@ -0,0 +1,74 @@ +package wire + +import ( + "carrot_bbs/internal/cache" + "carrot_bbs/internal/config" + "carrot_bbs/internal/handler" + "carrot_bbs/internal/pkg/sse" + "carrot_bbs/internal/repository" + "carrot_bbs/internal/service" + + "github.com/google/wire" + "gorm.io/gorm" +) + +// HandlerSet Handler 层 Provider Set +var HandlerSet = wire.NewSet( + // 直接使用构造函数的 Handler + handler.NewUserHandler, + handler.NewCommentHandler, + handler.NewNotificationHandler, + handler.NewUploadHandler, + handler.NewPushHandler, + handler.NewStickerHandler, + handler.NewVoteHandler, + handler.NewScheduleHandler, + + // 需要特殊处理的 Handler + ProvidePostHandler, + ProvideMessageHandler, + ProvideSystemMessageHandler, + ProvideGroupHandler, + ProvideGorseHandler, +) + +// ProvidePostHandler 提供帖子处理器 +func ProvidePostHandler( + postService service.PostService, + userService service.UserService, +) *handler.PostHandler { + return handler.NewPostHandler(postService, userService) +} + +// ProvideMessageHandler 提供消息处理器 +func ProvideMessageHandler( + chatService service.ChatService, + messageService *service.MessageService, + userService service.UserService, + groupService service.GroupService, + sseHub *sse.Hub, +) *handler.MessageHandler { + return handler.NewMessageHandler(chatService, messageService, userService, groupService, sseHub) +} + +// ProvideSystemMessageHandler 提供系统消息处理器 +func ProvideSystemMessageHandler( + systemMsgService service.SystemMessageService, + systemNotificationRepo *repository.SystemNotificationRepository, + cacheBackend cache.Cache, +) *handler.SystemMessageHandler { + return handler.NewSystemMessageHandler(systemMsgService, systemNotificationRepo, cacheBackend) +} + +// ProvideGroupHandler 提供群组处理器 +func ProvideGroupHandler( + groupService service.GroupService, + userService service.UserService, +) *handler.GroupHandler { + return handler.NewGroupHandler(groupService, userService) +} + +// ProvideGorseHandler 提供 Gorse 处理器 +func ProvideGorseHandler(cfg *config.Config, db *gorm.DB) *handler.GorseHandler { + return handler.NewGorseHandler(cfg.Gorse, db) +} diff --git a/internal/wire/infrastructure.go b/internal/wire/infrastructure.go new file mode 100644 index 0000000..908b9b4 --- /dev/null +++ b/internal/wire/infrastructure.go @@ -0,0 +1,121 @@ +package wire + +import ( + "log" + "time" + + "carrot_bbs/internal/cache" + "carrot_bbs/internal/config" + "carrot_bbs/internal/model" + "carrot_bbs/internal/pkg/email" + "carrot_bbs/internal/pkg/gorse" + "carrot_bbs/internal/pkg/openai" + "carrot_bbs/internal/pkg/redis" + "carrot_bbs/internal/pkg/s3" + "carrot_bbs/internal/pkg/sse" + "carrot_bbs/internal/repository" + + "github.com/google/wire" + "gorm.io/gorm" +) + +// InfrastructureSet 基础设施 Provider Set +var InfrastructureSet = wire.NewSet( + // 配置 + ProvideConfig, + + // 数据库 + ProvideDB, + + // 事务管理器 + ProvideTransactionManager, + + // Redis 和缓存 + ProvideRedisClient, + ProvideCache, + + // SSE Hub + ProvideSSEHub, + + // 外部服务客户端 + ProvideGorseClient, + ProvideOpenAIClient, + ProvideEmailClient, + ProvideS3Client, +) + +// ProvideConfig 加载配置 +func ProvideConfig() (*config.Config, error) { + return config.Load("configs/config.yaml") +} + +// ProvideDB 提供数据库连接 +func ProvideDB(cfg *config.Config) (*gorm.DB, error) { + return model.NewDB(&cfg.Database) +} + +// ProvideRedisClient 提供 Redis 客户端 +func ProvideRedisClient(cfg *config.Config) *redis.Client { + client, err := redis.New(&cfg.Redis) + if err != nil { + log.Printf("[WARNING] Failed to connect to Redis: %v, falling back to memory cache", err) + return nil + } + return client +} + +// ProvideCache 提供缓存实例 +func ProvideCache(cfg *config.Config, redisClient *redis.Client) cache.Cache { + cache.Configure(cache.Settings{ + Enabled: cfg.Cache.Enabled, + KeyPrefix: cfg.Cache.KeyPrefix, + DefaultTTL: time.Duration(cfg.Cache.DefaultTTL) * time.Second, + NullTTL: time.Duration(cfg.Cache.NullTTL) * time.Second, + JitterRatio: cfg.Cache.JitterRatio, + PostListTTL: time.Duration(cfg.Cache.Modules.PostList) * time.Second, + ConversationTTL: time.Duration(cfg.Cache.Modules.Conversation) * time.Second, + UnreadCountTTL: time.Duration(cfg.Cache.Modules.UnreadCount) * time.Second, + GroupMembersTTL: time.Duration(cfg.Cache.Modules.GroupMembers) * time.Second, + DisableFlushDB: cfg.Cache.DisableFlushDB, + }) + + // 直接创建 RedisCache 实例,不依赖全局变量 + if redisClient == nil { + log.Println("[Wire] Warning: Redis client is nil, cache will not be available") + return nil + } + + cacheInstance := cache.NewRedisCache(redisClient) + log.Println("[Wire] Initialized Redis cache via dependency injection") + return cacheInstance +} + +// ProvideSSEHub 提供 SSE Hub +func ProvideSSEHub() *sse.Hub { + return sse.NewHub() +} + +// ProvideGorseClient 提供 Gorse 客户端 +func ProvideGorseClient(cfg *config.Config) gorse.Client { + return gorse.NewClient(gorse.ConfigFromAppConfig(&cfg.Gorse)) +} + +// ProvideOpenAIClient 提供 OpenAI 客户端 +func ProvideOpenAIClient(cfg *config.Config) openai.Client { + return openai.NewClient(openai.ConfigFromAppConfig(&cfg.OpenAI)) +} + +// ProvideEmailClient 提供邮件客户端 +func ProvideEmailClient(cfg *config.Config) email.Client { + return email.NewClient(email.ConfigFromAppConfig(&cfg.Email)) +} + +// ProvideS3Client 提供 S3 客户端 +func ProvideS3Client(cfg *config.Config) (*s3.Client, error) { + return s3.New(&cfg.S3) +} + +// ProvideTransactionManager 提供事务管理器 +func ProvideTransactionManager(db *gorm.DB) repository.TransactionManager { + return repository.NewTransactionManager(db) +} diff --git a/internal/wire/provider_set.go b/internal/wire/provider_set.go new file mode 100644 index 0000000..f9d60ec --- /dev/null +++ b/internal/wire/provider_set.go @@ -0,0 +1,11 @@ +package wire + +import "github.com/google/wire" + +// AllSet 包含所有 Provider Set +var AllSet = wire.NewSet( + InfrastructureSet, + RepositorySet, + ServiceSet, + HandlerSet, +) diff --git a/internal/wire/repository.go b/internal/wire/repository.go new file mode 100644 index 0000000..1c62275 --- /dev/null +++ b/internal/wire/repository.go @@ -0,0 +1,29 @@ +package wire + +import ( + "carrot_bbs/internal/repository" + + "github.com/google/wire" +) + +// RepositorySet Repository 层 Provider Set +var RepositorySet = wire.NewSet( + repository.NewUserRepository, + repository.NewPostRepository, + repository.NewCommentRepository, + repository.NewMessageRepository, + repository.NewNotificationRepository, + repository.NewPushRecordRepository, + repository.NewDeviceTokenRepository, + repository.NewSystemNotificationRepository, + repository.NewGroupRepository, + repository.NewStickerRepository, + repository.NewVoteRepository, + repository.NewScheduleRepository, +) + +// Note: 以下 Repository 返回接口类型而非指针,需要单独处理 +// - ScheduleRepository (repository.ScheduleRepository) +// - StickerRepository (repository.StickerRepository) +// - GroupRepository (repository.GroupRepository) +// 这些已经在各自的 New* 函数中正确返回接口类型,Wire 可以自动处理 diff --git a/internal/wire/service.go b/internal/wire/service.go new file mode 100644 index 0000000..45f0d8e --- /dev/null +++ b/internal/wire/service.go @@ -0,0 +1,189 @@ +package wire + +import ( + "carrot_bbs/internal/cache" + "carrot_bbs/internal/config" + "carrot_bbs/internal/pkg/email" + "carrot_bbs/internal/pkg/gorse" + "carrot_bbs/internal/pkg/openai" + "carrot_bbs/internal/pkg/s3" + "carrot_bbs/internal/pkg/sse" + "carrot_bbs/internal/repository" + "carrot_bbs/internal/service" + + "github.com/google/wire" + "gorm.io/gorm" +) + +// ServiceSet Service 层 Provider Set +var ServiceSet = wire.NewSet( + // JWT 服务(需要配置参数) + ProvideJWTService, + + // 基础服务 + ProvideEmailService, + ProvidePostAIService, + + // 核心服务(按依赖顺序) + ProvidePushService, + ProvideSystemMessageService, + ProvidePostService, + ProvideCommentService, + ProvideMessageService, + ProvideNotificationService, + ProvideUserService, + ProvideStickerService, + ProvideVoteService, + ProvideChatService, + ProvideScheduleService, + ProvideGroupService, + ProvideUploadService, +) + +// ProvideJWTService 提供 JWT 服务 +func ProvideJWTService(cfg *config.Config) *service.JWTService { + return service.NewJWTService( + cfg.JWT.Secret, + int64(cfg.JWT.AccessTokenExpire.Seconds()), + int64(cfg.JWT.RefreshTokenExpire.Seconds()), + ) +} + +// ProvideEmailService 提供邮件服务 +func ProvideEmailService(client email.Client) service.EmailService { + return service.NewEmailService(client) +} + +// ProvidePostAIService 提供 AI 服务 +func ProvidePostAIService(client openai.Client) *service.PostAIService { + return service.NewPostAIService(client) +} + +// ProvidePushService 提供推送服务 +func ProvidePushService( + pushRepo *repository.PushRecordRepository, + deviceTokenRepo *repository.DeviceTokenRepository, + messageRepo *repository.MessageRepository, + sseHub *sse.Hub, +) service.PushService { + return service.NewPushService(pushRepo, deviceTokenRepo, messageRepo, sseHub) +} + +// ProvideSystemMessageService 提供系统消息服务 +func ProvideSystemMessageService( + notifyRepo *repository.SystemNotificationRepository, + pushService service.PushService, + userRepo *repository.UserRepository, + postRepo *repository.PostRepository, + cacheBackend cache.Cache, +) service.SystemMessageService { + return service.NewSystemMessageService(notifyRepo, pushService, userRepo, postRepo, cacheBackend) +} + +// ProvidePostService 提供帖子服务 +func ProvidePostService( + postRepo *repository.PostRepository, + systemMessageService service.SystemMessageService, + gorseClient gorse.Client, + postAIService *service.PostAIService, + cacheBackend cache.Cache, + txManager repository.TransactionManager, +) service.PostService { + return service.NewPostService(postRepo, systemMessageService, gorseClient, postAIService, cacheBackend, txManager) +} + +// ProvideCommentService 提供评论服务 +func ProvideCommentService( + commentRepo *repository.CommentRepository, + postRepo *repository.PostRepository, + systemMessageService service.SystemMessageService, + gorseClient gorse.Client, + postAIService *service.PostAIService, + cacheBackend cache.Cache, +) *service.CommentService { + return service.NewCommentService(commentRepo, postRepo, systemMessageService, gorseClient, postAIService, cacheBackend) +} + +// ProvideMessageService 提供消息服务 +func ProvideMessageService( + db *gorm.DB, + messageRepo *repository.MessageRepository, + cacheBackend cache.Cache, +) *service.MessageService { + return service.NewMessageService(db, messageRepo, cacheBackend) +} + +// ProvideNotificationService 提供通知服务 +func ProvideNotificationService( + notificationRepo *repository.NotificationRepository, + cacheBackend cache.Cache, +) *service.NotificationService { + return service.NewNotificationService(notificationRepo, cacheBackend) +} + +// ProvideUserService 提供用户服务 +func ProvideUserService( + userRepo *repository.UserRepository, + systemMessageService service.SystemMessageService, + emailService service.EmailService, + cacheBackend cache.Cache, +) service.UserService { + return service.NewUserService(userRepo, systemMessageService, emailService, cacheBackend) +} + +// ProvideStickerService 提供表情服务 +func ProvideStickerService( + stickerRepo repository.StickerRepository, +) service.StickerService { + return service.NewStickerService(stickerRepo) +} + +// ProvideVoteService 提供投票服务 +func ProvideVoteService( + voteRepo *repository.VoteRepository, + postRepo *repository.PostRepository, + cache cache.Cache, + postAIService *service.PostAIService, + systemMessageService service.SystemMessageService, +) *service.VoteService { + return service.NewVoteService(voteRepo, postRepo, cache, postAIService, systemMessageService) +} + +// ProvideChatService 提供聊天服务 +// Note: sensitiveService 传 nil,与 main.go 保持一致 +func ProvideChatService( + db *gorm.DB, + messageRepo *repository.MessageRepository, + userRepo *repository.UserRepository, + sseHub *sse.Hub, + cacheBackend cache.Cache, +) service.ChatService { + return service.NewChatService(db, messageRepo, userRepo, nil, sseHub, cacheBackend) +} + +// ProvideScheduleService 提供日程服务 +func ProvideScheduleService( + scheduleRepo repository.ScheduleRepository, +) service.ScheduleService { + return service.NewScheduleService(scheduleRepo) +} + +// ProvideGroupService 提供群组服务 +func ProvideGroupService( + db *gorm.DB, + groupRepo repository.GroupRepository, + userRepo *repository.UserRepository, + messageRepo *repository.MessageRepository, + sseHub *sse.Hub, + cacheBackend cache.Cache, +) service.GroupService { + return service.NewGroupService(db, groupRepo, userRepo, messageRepo, sseHub, cacheBackend) +} + +// ProvideUploadService 提供上传服务 +func ProvideUploadService( + s3Client *s3.Client, + userService service.UserService, +) *service.UploadService { + return service.NewUploadService(s3Client, userService) +}