refactor: improve system stability, performance, and code structure
All checks were successful
Build Backend / build (push) Successful in 3m2s
Build Backend / build-docker (push) Successful in 2m44s

This commit introduces several architectural improvements and optimizations across the codebase:

- **Performance & Reliability**:
  - Implemented Redis pipelining in `ConversationCache.CacheMessage` to reduce network round-trips.
  - Added a circuit breaker to the JPush client to prevent cascading failures.
  - Introduced batch deletion and batch member addition capabilities in repositories.
  - Added message idempotency support using `client_msg_id` and a Redis-based cache.
  - Optimized WebSocket handling with connection limits (total and per-user) and improved error logging.

- **Code Refactoring**:
  - Refactored `Router` to use a `RouterDeps` struct, simplifying the constructor and improving maintainability.
  - Unified model ID generation logic using new `id_helper.go` (supporting UUID and Snowflake).
  - Standardized JSON serialization/deserialization in models using `json_helper.go`.
  - Refactored DTO conversion logic, specifically for `UserResponse` (using functional options) and `Report` responses.
  - Removed redundant/deprecated DTOs like `PostDetailResponse` and `TradeItemDetailResponse`.

- **Cache Improvements**:
  - Enhanced `LayeredCache` with `SetRaw` to avoid double-encoding when promoting values from Redis to local cache.
  - Added `DeleteBatch` support to the cache interface.

- **Other Changes**:
  - Cleaned up `config.go` by removing redundant default values and explicit environment variable overrides.
  - Improved WebSocket registration flow to handle connection limits gracefully.
This commit is contained in:
2026-05-04 13:07:03 +08:00
parent b2b55ea52d
commit ee78071d4d
65 changed files with 1293 additions and 975 deletions

View File

@@ -32,6 +32,8 @@ type Cache interface {
Increment(key string) int64
// IncrementBy 增加指定值
IncrementBy(key string, value int64) int64
// DeleteBatch 批量删除多个 key使用 Pipeline 减少网络往返)
DeleteBatch(keys []string)
// ==================== Hash 操作 ====================
// HSet 设置 Hash 字段

View File

@@ -6,6 +6,8 @@ import (
"fmt"
"time"
"github.com/redis/go-redis/v9"
"with_you/internal/model"
"go.uber.org/zap"
@@ -618,11 +620,11 @@ func (c *ConversationCache) GetMessagesBeforeSeq(ctx context.Context, convID str
return messages, nil
}
// CacheMessage 缓存单条消息(立即写入缓存
// 写入 Hash、Sorted Set、更新计数
// CacheMessage 缓存单条消息(使用 Redis Pipeline 减少网络往返
func (c *ConversationCache) CacheMessage(ctx context.Context, convID string, msg *model.Message) error {
hashKey := MessageHashKey(convID)
indexKey := MessageIndexKey(convID)
countKey := MessageCountKey(convID)
msgData := MessageCacheDataFromModel(msg)
data, err := json.Marshal(msgData)
@@ -630,23 +632,34 @@ func (c *ConversationCache) CacheMessage(ctx context.Context, convID string, msg
return fmt.Errorf("failed to marshal message: %w", err)
}
// HSET 消息详情
if err := c.cache.HSet(ctx, hashKey, fmt.Sprintf("%d", msg.Seq), string(data)); err != nil {
hashKey = ResolveKey(hashKey)
indexKey = ResolveKey(indexKey)
countKey = ResolveKey(countKey)
redisCache, ok := c.cache.(*RedisCache)
if ok {
rdb := redisCache.GetRedisClient().GetClient()
pipe := rdb.Pipeline()
pipe.HSet(ctx, hashKey, fmt.Sprintf("%d", msg.Seq), string(data))
pipe.ZAdd(ctx, indexKey, redis.Z{Score: float64(msg.Seq), Member: fmt.Sprintf("%d", msg.Seq)})
pipe.Expire(ctx, hashKey, c.settings.MessageDetailTTL)
pipe.Expire(ctx, indexKey, c.settings.MessageIndexTTL)
pipe.Incr(ctx, countKey)
if _, err := pipe.Exec(ctx); err != nil {
return fmt.Errorf("failed to pipeline cache message: %w", err)
}
return nil
}
if err := c.cache.HSet(ctx, MessageHashKey(convID), fmt.Sprintf("%d", msg.Seq), string(data)); err != nil {
return fmt.Errorf("failed to set hash: %w", err)
}
// ZADD 消息索引
if err := c.cache.ZAdd(ctx, indexKey, float64(msg.Seq), fmt.Sprintf("%d", msg.Seq)); err != nil {
if err := c.cache.ZAdd(ctx, MessageIndexKey(convID), float64(msg.Seq), fmt.Sprintf("%d", msg.Seq)); err != nil {
return fmt.Errorf("failed to add to sorted set: %w", err)
}
// 设置 TTL
c.cache.Expire(ctx, hashKey, c.settings.MessageDetailTTL)
c.cache.Expire(ctx, indexKey, c.settings.MessageIndexTTL)
// INCR 消息计数
c.cache.Expire(ctx, MessageHashKey(convID), c.settings.MessageDetailTTL)
c.cache.Expire(ctx, MessageIndexKey(convID), c.settings.MessageIndexTTL)
c.cache.Incr(ctx, MessageCountKey(convID))
return nil
}

View File

@@ -35,11 +35,12 @@ const (
PrefixChannelsAllList = "channels:all_list"
// 消息缓存相关
keyPrefixMsgHash = "msg_hash" // 消息详情 Hash
keyPrefixMsgIndex = "msg_index" // 消息索引 Sorted Set
keyPrefixMsgCount = "msg_count" // 消息计数
keyPrefixMsgSeq = "msg_seq" // Seq 计数器
keyPrefixMsgPage = "msg_page" // 分页缓存
keyPrefixMsgHash = "msg_hash" // 消息详情 Hash
keyPrefixMsgIndex = "msg_index" // 消息索引 Sorted Set
keyPrefixMsgCount = "msg_count" // 消息计数
keyPrefixMsgSeq = "msg_seq" // Seq 计数器
keyPrefixMsgPage = "msg_page" // 分页缓存
keyPrefixMsgIdempotent = "msg_idem" // 消息幂等键
)
// PostListKey 生成帖子列表缓存键
@@ -221,3 +222,13 @@ func MessagePageKey(convID string, page, pageSize int) string {
func InvalidateMessagePages(cache Cache, conversationID string) {
cache.DeleteByPrefix(fmt.Sprintf("%s:%s:", keyPrefixMsgPage, conversationID))
}
// MessageIdempotentKey 消息幂等键 (clientMsgID -> server MsgID)
func MessageIdempotentKey(senderID, clientMsgID string) string {
return fmt.Sprintf("%s:%s:%s", keyPrefixMsgIdempotent, senderID, clientMsgID)
}
// PushDedupKey 推送去重键,防止同一消息重复推送给同一用户
func PushDedupKey(userID, messageID string) string {
return fmt.Sprintf("push_dedup:%s:%s", userID, messageID)
}

View File

@@ -111,7 +111,11 @@ func (c *LayeredCache) Get(key string) (any, bool) {
c.mu.Unlock()
if ttl := c.getLocalTTL(); ttl > 0 {
c.local.Set(key, val, ttl)
if str, ok := val.(string); ok {
c.local.SetRaw(key, []byte(str), ttl)
} else {
c.local.Set(key, val, ttl)
}
}
return val, true
}
@@ -157,6 +161,23 @@ func (c *LayeredCache) DeleteByPrefix(prefix string) {
}
}
func (c *LayeredCache) DeleteBatch(keys []string) {
if !c.enabled || len(keys) == 0 {
return
}
c.mu.Lock()
c.stats.Invalidates += int64(len(keys))
c.mu.Unlock()
for _, k := range keys {
c.local.Delete(k)
}
if c.redis != nil {
c.redis.DeleteBatch(keys)
}
}
func (c *LayeredCache) Clear() {
c.local.Clear()
if c.redis != nil {

33
internal/cache/lru.go vendored
View File

@@ -87,6 +87,39 @@ func (l *LRU) set(key string, value any, ttl time.Duration) {
}
}
// SetRaw stores raw bytes directly without re-serializing.
// This avoids double-encoding when promoting values from Redis to local cache.
func (l *LRU) SetRaw(key string, raw []byte, ttl time.Duration) {
var expires time.Time
if ttl > 0 {
expires = time.Now().Add(ttl)
}
l.lock.Lock()
defer l.lock.Unlock()
if ent, ok := l.items[key]; ok {
l.evictList.MoveToFront(ent)
e := ent.Value.(*lruEntry)
e.value = raw
e.expires = expires
if e.generation != l.currentGeneration {
e.generation = l.currentGeneration
l.len++
}
return
}
ent := &lruEntry{key, raw, expires, l.currentGeneration}
entry := l.evictList.PushFront(ent)
l.items[key] = entry
l.len++
if l.evictList.Len() > l.size {
l.removeElement(l.evictList.Back())
}
}
func (l *LRU) Get(key string) (any, bool) {
val, err := l.getItem(key)
if err != nil {

View File

@@ -106,11 +106,20 @@ func (l *LRUStriped) Name() string {
}
func (l *LRUStriped) DeleteMulti(keys []string) error {
var err error
for _, key := range keys {
l.Delete(key)
}
return err
return nil
}
func (l *LRUStriped) DeleteBatch(keys []string) {
for _, key := range keys {
l.Delete(key)
}
}
func (l *LRUStriped) SetRaw(key string, raw []byte, ttl time.Duration) {
l.keyBucket(key).SetRaw(key, raw, ttl)
}
func (l *LRUStriped) GetMulti(keys []string, values []any) []error {

View File

@@ -82,7 +82,6 @@ func (c *RedisCache) Delete(key string) {
// DeleteByPrefix 根据前缀删除缓存
func (c *RedisCache) DeleteByPrefix(prefix string) {
prefix = normalizePrefix(prefix)
// 使用原生客户端执行SCAN命令
rdb := c.client.GetClient()
var cursor uint64
for {
@@ -112,6 +111,30 @@ func (c *RedisCache) DeleteByPrefix(prefix string) {
}
}
// DeleteBatch 批量删除多个 key使用 Redis Pipeline 减少网络往返)
func (c *RedisCache) DeleteBatch(keys []string) {
if len(keys) == 0 {
return
}
normalizedKeys := make([]string, len(keys))
for i, k := range keys {
normalizedKeys[i] = normalizeKey(k)
}
recordInvalidateMultiple(int64(len(normalizedKeys)))
// 使用 pipeline 批量删除
pipe := c.client.GetClient().Pipeline()
for _, k := range normalizedKeys {
pipe.Del(c.ctx, k)
}
if _, err := pipe.Exec(c.ctx); err != nil {
zap.L().Error("Failed to batch delete keys",
zap.Int("count", len(normalizedKeys)),
zap.Error(err),
)
}
}
// Clear 清空所有缓存
func (c *RedisCache) Clear() {
if settings.DisableFlushDB {

View File

@@ -1,10 +1,7 @@
package config
import (
"cmp"
"fmt"
"os"
"strconv"
"strings"
"time"
@@ -149,14 +146,6 @@ func Load(configPath string) (*Config, error) {
viper.SetDefault("grpc.tls_enabled", false)
viper.SetDefault("grpc.tls_cert_file", "")
viper.SetDefault("grpc.tls_key_file", "")
// Casbin 默认值
viper.SetDefault("casbin.model_path", "./configs/casbin/model.conf")
viper.SetDefault("casbin.auto_save", true)
viper.SetDefault("casbin.auto_load", true)
viper.SetDefault("casbin.enable_cache", true)
viper.SetDefault("casbin.cache_ttl", 300)
viper.SetDefault("casbin.skip_routes", []string{"/health", "/api/v1/auth/login", "/api/v1/auth/register"})
// Casbin 默认值
viper.SetDefault("casbin.model_path", "./configs/casbin/model.conf")
viper.SetDefault("casbin.auto_save", true)
viper.SetDefault("casbin.auto_load", true)
@@ -198,96 +187,7 @@ func Load(configPath string) (*Config, error) {
cfg.JWT.AccessTokenExpire = time.Duration(viper.GetInt("jwt.access_token_expire")) * time.Second
cfg.JWT.RefreshTokenExpire = time.Duration(viper.GetInt("jwt.refresh_token_expire")) * time.Second
// 环境变量覆盖(显式处理敏感配置)
cfg.JWT.Secret = getEnvOrDefault("APP_JWT_SECRET", cfg.JWT.Secret)
cfg.Database.SQLite.Path = getEnvOrDefault("APP_DATABASE_SQLITE_PATH", cfg.Database.SQLite.Path)
cfg.Database.Postgres.Host = getEnvOrDefault("APP_DATABASE_POSTGRES_HOST", cfg.Database.Postgres.Host)
cfg.Database.Postgres.Port, _ = strconv.Atoi(getEnvOrDefault("APP_DATABASE_POSTGRES_PORT", fmt.Sprintf("%d", cfg.Database.Postgres.Port)))
cfg.Database.Postgres.User = getEnvOrDefault("APP_DATABASE_POSTGRES_USER", cfg.Database.Postgres.User)
cfg.Database.Postgres.Password = getEnvOrDefault("APP_DATABASE_POSTGRES_PASSWORD", cfg.Database.Postgres.Password)
cfg.Database.Postgres.DBName = getEnvOrDefault("APP_DATABASE_POSTGRES_DBNAME", cfg.Database.Postgres.DBName)
cfg.Database.LogLevel = getEnvOrDefault("APP_DATABASE_LOG_LEVEL", cfg.Database.LogLevel)
cfg.Database.SlowThresholdMs, _ = strconv.Atoi(getEnvOrDefault("APP_DATABASE_SLOW_THRESHOLD_MS", fmt.Sprintf("%d", cfg.Database.SlowThresholdMs)))
cfg.Database.IgnoreRecordNotFound, _ = strconv.ParseBool(getEnvOrDefault("APP_DATABASE_IGNORE_RECORD_NOT_FOUND", fmt.Sprintf("%t", cfg.Database.IgnoreRecordNotFound)))
cfg.Database.ParameterizedQueries, _ = strconv.ParseBool(getEnvOrDefault("APP_DATABASE_PARAMETERIZED_QUERIES", fmt.Sprintf("%t", cfg.Database.ParameterizedQueries)))
cfg.Redis.Redis.Host = getEnvOrDefault("APP_REDIS_REDIS_HOST", cfg.Redis.Redis.Host)
cfg.Redis.Redis.Port, _ = strconv.Atoi(getEnvOrDefault("APP_REDIS_REDIS_PORT", fmt.Sprintf("%d", cfg.Redis.Redis.Port)))
cfg.Redis.Redis.Password = getEnvOrDefault("APP_REDIS_REDIS_PASSWORD", cfg.Redis.Redis.Password)
cfg.Redis.Redis.DB, _ = strconv.Atoi(getEnvOrDefault("APP_REDIS_REDIS_DB", fmt.Sprintf("%d", cfg.Redis.Redis.DB)))
cfg.Redis.Miniredis.Host = getEnvOrDefault("APP_REDIS_MINIREDIS_HOST", cfg.Redis.Miniredis.Host)
cfg.Redis.Miniredis.Port, _ = strconv.Atoi(getEnvOrDefault("APP_REDIS_MINIREDIS_PORT", fmt.Sprintf("%d", cfg.Redis.Miniredis.Port)))
cfg.Redis.Type = getEnvOrDefault("APP_REDIS_TYPE", cfg.Redis.Type)
cfg.Cache.KeyPrefix = getEnvOrDefault("APP_CACHE_KEY_PREFIX", cfg.Cache.KeyPrefix)
cfg.Cache.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_CACHE_ENABLED", fmt.Sprintf("%t", cfg.Cache.Enabled)))
cfg.Cache.DisableFlushDB, _ = strconv.ParseBool(getEnvOrDefault("APP_CACHE_DISABLE_FLUSHDB", fmt.Sprintf("%t", cfg.Cache.DisableFlushDB)))
cfg.Cache.DefaultTTL, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_DEFAULT_TTL", fmt.Sprintf("%d", cfg.Cache.DefaultTTL)))
cfg.Cache.NullTTL, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_NULL_TTL", fmt.Sprintf("%d", cfg.Cache.NullTTL)))
cfg.Cache.JitterRatio, _ = strconv.ParseFloat(getEnvOrDefault("APP_CACHE_JITTER_RATIO", fmt.Sprintf("%.2f", cfg.Cache.JitterRatio)), 64)
cfg.Cache.Modules.PostList, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_POST_LIST_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.PostList)))
cfg.Cache.Modules.Conversation, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_CONVERSATION_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.Conversation)))
cfg.Cache.Modules.UnreadCount, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_UNREAD_COUNT_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.UnreadCount)))
cfg.Cache.Modules.GroupMembers, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_GROUP_MEMBERS_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.GroupMembers)))
cfg.S3.Endpoint = getEnvOrDefault("APP_S3_ENDPOINT", cfg.S3.Endpoint)
cfg.S3.AccessKey = getEnvOrDefault("APP_S3_ACCESS_KEY", cfg.S3.AccessKey)
cfg.S3.SecretKey = getEnvOrDefault("APP_S3_SECRET_KEY", cfg.S3.SecretKey)
cfg.S3.Bucket = getEnvOrDefault("APP_S3_BUCKET", cfg.S3.Bucket)
cfg.S3.Domain = getEnvOrDefault("APP_S3_DOMAIN", cfg.S3.Domain)
cfg.Server.Host = getEnvOrDefault("APP_SERVER_HOST", cfg.Server.Host)
cfg.Server.Port, _ = strconv.Atoi(getEnvOrDefault("APP_SERVER_PORT", fmt.Sprintf("%d", cfg.Server.Port)))
cfg.Server.Mode = getEnvOrDefault("APP_SERVER_MODE", cfg.Server.Mode)
cfg.OpenAI.BaseURL = getEnvOrDefault("APP_OPENAI_BASE_URL", cfg.OpenAI.BaseURL)
cfg.OpenAI.APIKey = getEnvOrDefault("APP_OPENAI_API_KEY", cfg.OpenAI.APIKey)
cfg.OpenAI.ModerationModel = getEnvOrDefault("APP_OPENAI_MODERATION_MODEL", cfg.OpenAI.ModerationModel)
cfg.OpenAI.ModerationMaxImagesPerRequest, _ = strconv.Atoi(getEnvOrDefault("APP_OPENAI_MODERATION_MAX_IMAGES_PER_REQUEST", fmt.Sprintf("%d", cfg.OpenAI.ModerationMaxImagesPerRequest)))
cfg.OpenAI.RequestTimeout, _ = strconv.Atoi(getEnvOrDefault("APP_OPENAI_REQUEST_TIMEOUT", fmt.Sprintf("%d", cfg.OpenAI.RequestTimeout)))
cfg.OpenAI.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_OPENAI_ENABLED", fmt.Sprintf("%t", cfg.OpenAI.Enabled)))
cfg.OpenAI.StrictModeration, _ = strconv.ParseBool(getEnvOrDefault("APP_OPENAI_STRICT_MODERATION", fmt.Sprintf("%t", cfg.OpenAI.StrictModeration)))
// TencentTMS 环境变量覆盖
cfg.TencentTMS.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_TENCENT_TMS_ENABLED", fmt.Sprintf("%t", cfg.TencentTMS.Enabled)))
cfg.TencentTMS.SecretID = getEnvOrDefault("APP_TENCENT_TMS_SECRET_ID", cfg.TencentTMS.SecretID)
cfg.TencentTMS.SecretKey = getEnvOrDefault("APP_TENCENT_TMS_SECRET_KEY", cfg.TencentTMS.SecretKey)
cfg.TencentTMS.Region = getEnvOrDefault("APP_TENCENT_TMS_REGION", cfg.TencentTMS.Region)
cfg.TencentTMS.BizType = getEnvOrDefault("APP_TENCENT_TMS_BIZ_TYPE", cfg.TencentTMS.BizType)
cfg.TencentTMS.Timeout, _ = strconv.Atoi(getEnvOrDefault("APP_TENCENT_TMS_TIMEOUT", fmt.Sprintf("%d", cfg.TencentTMS.Timeout)))
cfg.Email.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_EMAIL_ENABLED", fmt.Sprintf("%t", cfg.Email.Enabled)))
cfg.Email.Host = getEnvOrDefault("APP_EMAIL_HOST", cfg.Email.Host)
cfg.Email.Port, _ = strconv.Atoi(getEnvOrDefault("APP_EMAIL_PORT", fmt.Sprintf("%d", cfg.Email.Port)))
cfg.Email.Username = getEnvOrDefault("APP_EMAIL_USERNAME", cfg.Email.Username)
cfg.Email.Password = getEnvOrDefault("APP_EMAIL_PASSWORD", cfg.Email.Password)
cfg.Email.FromAddress = getEnvOrDefault("APP_EMAIL_FROM_ADDRESS", cfg.Email.FromAddress)
cfg.Email.FromName = getEnvOrDefault("APP_EMAIL_FROM_NAME", cfg.Email.FromName)
cfg.Email.UseTLS, _ = strconv.ParseBool(getEnvOrDefault("APP_EMAIL_USE_TLS", fmt.Sprintf("%t", cfg.Email.UseTLS)))
cfg.Email.InsecureSkipVerify, _ = strconv.ParseBool(getEnvOrDefault("APP_EMAIL_INSECURE_SKIP_VERIFY", fmt.Sprintf("%t", cfg.Email.InsecureSkipVerify)))
cfg.Email.Timeout, _ = strconv.Atoi(getEnvOrDefault("APP_EMAIL_TIMEOUT", fmt.Sprintf("%d", cfg.Email.Timeout)))
// GRPC 环境变量覆盖
cfg.GRPC.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_GRPC_ENABLED", fmt.Sprintf("%t", cfg.GRPC.Enabled)))
cfg.GRPC.Port, _ = strconv.Atoi(getEnvOrDefault("APP_GRPC_PORT", fmt.Sprintf("%d", cfg.GRPC.Port)))
cfg.GRPC.TLSEnabled, _ = strconv.ParseBool(getEnvOrDefault("APP_GRPC_TLS_ENABLED", fmt.Sprintf("%t", cfg.GRPC.TLSEnabled)))
cfg.GRPC.TLSCertFile = getEnvOrDefault("APP_GRPC_TLS_CERT_FILE", cfg.GRPC.TLSCertFile)
cfg.GRPC.TLSKeyFile = getEnvOrDefault("APP_GRPC_TLS_KEY_FILE", cfg.GRPC.TLSKeyFile)
// Casbin 环境变量覆盖
cfg.Casbin.ModelPath = getEnvOrDefault("APP_CASBIN_MODEL_PATH", cfg.Casbin.ModelPath)
cfg.Casbin.AutoSave, _ = strconv.ParseBool(getEnvOrDefault("APP_CASBIN_AUTO_SAVE", fmt.Sprintf("%t", cfg.Casbin.AutoSave)))
cfg.Casbin.AutoLoad, _ = strconv.ParseBool(getEnvOrDefault("APP_CASBIN_AUTO_LOAD", fmt.Sprintf("%t", cfg.Casbin.AutoLoad)))
cfg.Casbin.EnableCache, _ = strconv.ParseBool(getEnvOrDefault("APP_CASBIN_ENABLE_CACHE", fmt.Sprintf("%t", cfg.Casbin.EnableCache)))
cfg.Casbin.CacheTTL, _ = strconv.Atoi(getEnvOrDefault("APP_CASBIN_CACHE_TTL", fmt.Sprintf("%d", cfg.Casbin.CacheTTL)))
// Encryption 环境变量覆盖
cfg.Encryption.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_ENCRYPTION_ENABLED", fmt.Sprintf("%t", cfg.Encryption.Enabled)))
cfg.Encryption.Key = getEnvOrDefault("APP_ENCRYPTION_KEY", cfg.Encryption.Key)
cfg.Encryption.KeyVersion, _ = strconv.Atoi(getEnvOrDefault("APP_ENCRYPTION_KEY_VERSION", fmt.Sprintf("%d", cfg.Encryption.KeyVersion)))
// Sensitive 环境变量覆盖
cfg.Sensitive.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_SENSITIVE_ENABLED", fmt.Sprintf("%t", cfg.Sensitive.Enabled)))
cfg.Sensitive.ReplaceStr = getEnvOrDefault("APP_SENSITIVE_REPLACE_STR", cfg.Sensitive.ReplaceStr)
cfg.Sensitive.MinMatchLen, _ = strconv.Atoi(getEnvOrDefault("APP_SENSITIVE_MIN_MATCH_LEN", fmt.Sprintf("%d", cfg.Sensitive.MinMatchLen)))
cfg.Sensitive.LoadFromDB, _ = strconv.ParseBool(getEnvOrDefault("APP_SENSITIVE_LOAD_FROM_DB", fmt.Sprintf("%t", cfg.Sensitive.LoadFromDB)))
cfg.Sensitive.LoadFromRedis, _ = strconv.ParseBool(getEnvOrDefault("APP_SENSITIVE_LOAD_FROM_REDIS", fmt.Sprintf("%t", cfg.Sensitive.LoadFromRedis)))
cfg.Sensitive.RedisKeyPrefix = getEnvOrDefault("APP_SENSITIVE_REDIS_KEY_PREFIX", cfg.Sensitive.RedisKeyPrefix)
cfg.JPush.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_JPUSH_ENABLED", fmt.Sprintf("%t", cfg.JPush.Enabled)))
cfg.JPush.AppKey = getEnvOrDefault("APP_JPUSH_APP_KEY", cfg.JPush.AppKey)
cfg.JPush.MasterSecret = getEnvOrDefault("APP_JPUSH_MASTER_SECRET", cfg.JPush.MasterSecret)
cfg.JPush.Production, _ = strconv.ParseBool(getEnvOrDefault("APP_JPUSH_PRODUCTION", fmt.Sprintf("%t", cfg.JPush.Production)))
cfg.SetupSecret = getEnvOrDefault("APP_SETUP_SECRET", cfg.SetupSecret)
// Viper AutomaticEnv 已自动处理所有 APP_ 前缀的环境变量
// 安全检查生产模式必须设置JWT密钥
if cfg.Server.Mode != "debug" {
@@ -302,7 +202,4 @@ func Load(configPath string) (*Config, error) {
return &cfg, nil
}
// getEnvOrDefault 获取环境变量值,如果未设置则返回默认值
func getEnvOrDefault(key, defaultValue string) string {
return cmp.Or(os.Getenv(key), defaultValue)
}

View File

@@ -177,33 +177,6 @@ type PostResponse struct {
Channel *PostChannelBrief `json:"channel,omitempty"`
}
// PostDetailResponse 帖子详情响应
type PostDetailResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ChannelID *string `json:"channel_id,omitempty"`
Title string `json:"title"`
Content string `json:"content"`
Segments model.MessageSegments `json:"segments,omitempty"`
Images []PostImageResponse `json:"images"`
Status string `json:"status"`
LikesCount int `json:"likes_count"`
CommentsCount int `json:"comments_count"`
FavoritesCount int `json:"favorites_count"`
SharesCount int `json:"shares_count"`
ViewsCount int `json:"views_count"`
IsPinned bool `json:"is_pinned"`
IsLocked bool `json:"is_locked"`
IsVote bool `json:"is_vote"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ContentEditedAt string `json:"content_edited_at,omitempty"`
Author *UserResponse `json:"author"`
IsLiked bool `json:"is_liked"`
IsFavorited bool `json:"is_favorited"`
Channel *PostChannelBrief `json:"channel,omitempty"`
}
// ==================== Comment DTOs ====================
// CommentImageResponse 评论图片响应
@@ -454,8 +427,9 @@ type CreateConversationRequest struct {
// SendMessageRequest 发送消息请求
type SendMessageRequest struct {
Segments model.MessageSegments `json:"segments" binding:"required"` // 消息链(必须)
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID (string类型)
Segments model.MessageSegments `json:"segments" binding:"required"` // 消息链(必须)
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID (string类型)
ClientMsgID string `json:"client_msg_id,omitempty"` // 客户端消息ID幂等性去重
}
// MarkReadRequest 标记已读请求
@@ -751,10 +725,11 @@ type WSEventResponse struct {
// SendMessageParams 发送消息参数(用于 REST API
type SendMessageParams struct {
DetailType string `json:"detail_type"` // 消息类型: private, group
ConversationID string `json:"conversation_id"` // 会话ID
Segments model.MessageSegments `json:"segments"` // 消息内容(消息段数组)
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID
DetailType string `json:"detail_type"` // 消息类型: private, group
ConversationID string `json:"conversation_id"` // 会话ID
Segments model.MessageSegments `json:"segments"` // 消息内容(消息段数组)
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID
ClientMsgID string `json:"client_msg_id,omitempty"` // 客户端消息ID幂等性去重
}
// DeleteMsgParams 撤回消息参数

View File

@@ -124,50 +124,7 @@ func ConvertPostToResponse(post *model.Post, channelByID map[string]*model.Chann
}
}
// ConvertPostToDetailResponse 将Post转换为PostDetailResponsechannelByID 可为 nil
func ConvertPostToDetailResponse(post *model.Post, channelByID map[string]*model.Channel, 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,
ChannelID: post.ChannelID,
Title: post.Title,
Content: post.Content,
Segments: SegmentsOrDefault(post.Segments, 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),
ContentEditedAt: FormatTimePointer(post.ContentEditedAt),
Author: author,
IsLiked: isLiked,
IsFavorited: isFavorited,
Channel: postChannelBrief(post, channelByID),
}
}
// ConvertPostsToResponse 将Post列表转换为响应列表每个帖子独立检查点赞/收藏状态channelByID 可为 nil
// ConvertPostsToResponse 将Post列表转换为响应列表channelByID 可为 nil
func ConvertPostsToResponse(posts []*model.Post, channelByID map[string]*model.Channel, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
result := make([]*PostResponse, 0, len(posts))
for _, post := range posts {

View File

@@ -108,6 +108,12 @@ type AdminBatchHandleResponse struct {
// ==================== 转换函数 ====================
func baseAdminReportFields(report *model.Report) (string, string) {
reason := string(report.Reason)
status := string(report.Status)
return reason, status
}
// ConvertReportToResponse 将 Report 模型转换为响应
func ConvertReportToResponse(report *model.Report) *ReportResponse {
return &ReportResponse{
@@ -122,6 +128,19 @@ func ConvertReportToResponse(report *model.Report) *ReportResponse {
}
}
func fillAdminReportCommon(resp *AdminReportListResponse, report *model.Report, reporter *model.User, handler *model.User) {
if reporter != nil {
resp.Reporter = ConvertUserToResponse(reporter)
}
if handler != nil {
resp.Handler = ConvertUserToResponse(handler)
}
if report.HandledAt != nil {
handledAt := FormatTime(*report.HandledAt)
resp.HandledAt = &handledAt
}
}
// ConvertReportToAdminListResponse 将 Report 转换为管理端列表响应
func ConvertReportToAdminListResponse(report *model.Report, reporter *model.User, handler *model.User) *AdminReportListResponse {
resp := &AdminReportListResponse{
@@ -136,51 +155,34 @@ func ConvertReportToAdminListResponse(report *model.Report, reporter *model.User
Result: report.Result,
CreatedAt: FormatTime(report.CreatedAt),
}
if reporter != nil {
resp.Reporter = ConvertUserToResponse(reporter)
}
if handler != nil {
resp.Handler = ConvertUserToResponse(handler)
}
if report.HandledAt != nil {
handledAt := FormatTime(*report.HandledAt)
resp.HandledAt = &handledAt
}
fillAdminReportCommon(resp, report, reporter, handler)
return resp
}
// ConvertReportToAdminDetailResponse 将 Report 转换为管理端详情响应
func ConvertReportToAdminDetailResponse(report *model.Report, reporter *model.User, handler *model.User, targetContent *ReportTargetContent) *AdminReportDetailResponse {
resp := &AdminReportDetailResponse{
ID: report.ID,
ReporterID: report.ReporterID,
TargetType: string(report.TargetType),
TargetID: report.TargetID,
Reason: string(report.Reason),
Description: report.Description,
Status: string(report.Status),
HandledBy: report.HandledBy,
Result: report.Result,
CreatedAt: FormatTime(report.CreatedAt),
ID: report.ID,
ReporterID: report.ReporterID,
TargetType: string(report.TargetType),
TargetID: report.TargetID,
Reason: string(report.Reason),
Description: report.Description,
Status: string(report.Status),
HandledBy: report.HandledBy,
Result: report.Result,
CreatedAt: FormatTime(report.CreatedAt),
TargetContent: targetContent,
}
if reporter != nil {
resp.Reporter = ConvertUserToResponse(reporter)
}
if handler != nil {
resp.Handler = ConvertUserToResponse(handler)
}
if report.HandledAt != nil {
handledAt := FormatTime(*report.HandledAt)
resp.HandledAt = &handledAt
}
return resp
}

View File

@@ -45,8 +45,8 @@ func ConvertTradeItemToResponse(item *model.TradeItem, isFavorited bool) *TradeI
FavoritesCount: item.FavoritesCount,
IsFavorited: isFavorited,
Images: ConvertTradeImagesToResponse(item.Images),
CreatedAt: item.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
CreatedAt: FormatTime(item.CreatedAt),
UpdatedAt: FormatTime(item.UpdatedAt),
}
if len(item.Segments) > 0 {
resp.Segments = item.Segments
@@ -66,32 +66,6 @@ func ConvertTradeItemsToResponse(items []*model.TradeItem, favoriteMap map[strin
return result
}
func ConvertTradeItemToDetailResponse(item *model.TradeItem, isFavorited bool) *TradeItemDetailResponse {
if item == nil {
return nil
}
resp := &TradeItemDetailResponse{
ID: item.ID,
UserID: item.UserID,
TradeType: string(item.TradeType),
Category: string(item.Category),
Title: item.Title,
Content: item.Content,
Price: item.Price,
Condition: item.Condition,
Status: string(item.Status),
ViewsCount: item.ViewsCount,
FavoritesCount: item.FavoritesCount,
IsFavorited: isFavorited,
Images: ConvertTradeImagesToResponse(item.Images),
CreatedAt: item.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
if len(item.Segments) > 0 {
resp.Segments = item.Segments
}
if item.User != nil {
resp.Author = ConvertUserToResponse(item.User)
}
return resp
func ConvertTradeItemToDetailResponse(item *model.TradeItem, isFavorited bool) *TradeItemResponse {
return ConvertTradeItemToResponse(item, isFavorited)
}

View File

@@ -34,26 +34,6 @@ type TradeItemResponse struct {
UpdatedAt string `json:"updated_at"`
}
type TradeItemDetailResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
TradeType string `json:"trade_type"`
Category string `json:"category"`
Title string `json:"title"`
Content string `json:"content"`
Segments model.MessageSegments `json:"segments,omitempty"`
Images []TradeImageResponse `json:"images"`
Price *float64 `json:"price,omitempty"`
Condition string `json:"condition,omitempty"`
Status string `json:"status"`
ViewsCount int `json:"views_count"`
FavoritesCount int `json:"favorites_count"`
IsFavorited bool `json:"is_favorited"`
Author *UserResponse `json:"author"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type CreateTradeItemRequest struct {
TradeType string `json:"trade_type" binding:"required,oneof=sell buy"`
Category string `json:"category" binding:"required,oneof=digital book clothing daily cosmetics sport ticket other"`

View File

@@ -5,7 +5,20 @@ import (
"with_you/internal/pkg/utils"
)
// ==================== User 转换 ====================
type UserResponseOption func(*UserResponse)
func WithFollowing(isFollowing, isFollowingMe bool) UserResponseOption {
return func(r *UserResponse) {
r.IsFollowing = isFollowing
r.IsFollowingMe = isFollowingMe
}
}
func WithPostsCount(count int) UserResponseOption {
return func(r *UserResponse) {
r.PostsCount = count
}
}
// getAvatarOrDefault 获取头像URL如果为空则返回在线头像生成服务的URL
func getAvatarOrDefault(user *model.User) string {
@@ -22,8 +35,7 @@ func publicVerificationFields(user *model.User) (model.UserIdentity, model.Verif
return model.UserIdentityGeneral, model.VerificationStatusNone
}
// ConvertUserToResponse 将User转换为UserResponse公开信息不包含敏感数据
func ConvertUserToResponse(user *model.User) *UserResponse {
func baseUserResponse(user *model.User) *UserResponse {
if user == nil {
return nil
}
@@ -47,6 +59,18 @@ func ConvertUserToResponse(user *model.User) *UserResponse {
}
}
// ConvertUserToResponse 将User转换为UserResponse公开信息不包含敏感数据
func ConvertUserToResponse(user *model.User, opts ...UserResponseOption) *UserResponse {
r := baseUserResponse(user)
if r == nil {
return nil
}
for _, opt := range opts {
opt(r)
}
return r
}
// ConvertUserToSelfResponse 将User转换为UserSelfResponse本人信息包含敏感数据
func ConvertUserToSelfResponse(user *model.User, postsCount int) *UserSelfResponse {
if user == nil {
@@ -75,151 +99,6 @@ func ConvertUserToSelfResponse(user *model.User, postsCount int) *UserSelfRespon
}
}
func convertPrivacySettingsToResponse(ps *model.PrivacySettings) *PrivacySettingsResponse {
if ps == nil {
return nil
}
return &PrivacySettingsResponse{
FollowersVisibility: string(ps.FollowersVisibility),
FollowingVisibility: string(ps.FollowingVisibility),
PostsVisibility: string(ps.PostsVisibility),
FavoritesVisibility: string(ps.FavoritesVisibility),
}
}
// ConvertUserToResponseWithFollowing 将User转换为UserResponse包含关注状态公开信息
func ConvertUserToResponseWithFollowing(user *model.User, isFollowing bool) *UserResponse {
if user == nil {
return nil
}
identity, vs := publicVerificationFields(user)
return &UserResponse{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
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,
CreatedAt: FormatTime(user.CreatedAt),
Identity: identity,
VerificationStatus: vs,
}
}
// ConvertUserToResponseWithPostsCount 将User转换为UserResponse使用实时计算的帖子数量公开信息
func ConvertUserToResponseWithPostsCount(user *model.User, postsCount int) *UserResponse {
if user == nil {
return nil
}
identity, vs := publicVerificationFields(user)
return &UserResponse{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
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),
Identity: identity,
VerificationStatus: vs,
}
}
// ConvertUserToResponseWithMutualFollow 将User转换为UserResponse包含双向关注状态公开信息
func ConvertUserToResponseWithMutualFollow(user *model.User, isFollowing, isFollowingMe bool) *UserResponse {
if user == nil {
return nil
}
identity, vs := publicVerificationFields(user)
return &UserResponse{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
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),
Identity: identity,
VerificationStatus: vs,
}
}
// ConvertUserToResponseWithMutualFollowAndPostsCount 将User转换为UserResponse包含双向关注状态和实时计算的帖子数量公开信息
func ConvertUserToResponseWithMutualFollowAndPostsCount(user *model.User, isFollowing, isFollowingMe bool, postsCount int) *UserResponse {
if user == nil {
return nil
}
identity, vs := publicVerificationFields(user)
return &UserResponse{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
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),
Identity: identity,
VerificationStatus: vs,
}
}
// 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),
PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings),
Identity: user.Identity,
VerificationStatus: user.VerificationStatus,
}
}
// ConvertUserToDetailResponseWithPostsCount 将User转换为UserDetailResponse使用实时计算的帖子数量仅本人可见
func ConvertUserToDetailResponseWithPostsCount(user *model.User, postsCount int) *UserDetailResponse {
if user == nil {
@@ -248,31 +127,28 @@ func ConvertUserToDetailResponseWithPostsCount(user *model.User, postsCount int)
}
}
func convertPrivacySettingsToResponse(ps *model.PrivacySettings) *PrivacySettingsResponse {
if ps == nil {
return nil
}
return &PrivacySettingsResponse{
FollowersVisibility: string(ps.FollowersVisibility),
FollowingVisibility: string(ps.FollowingVisibility),
PostsVisibility: string(ps.PostsVisibility),
FavoritesVisibility: string(ps.FavoritesVisibility),
}
}
// ConvertUsersToResponse 将User列表转换为响应列表
func ConvertUsersToResponse(users []*model.User) []*UserResponse {
func ConvertUsersToResponse(users []*model.User, opts ...UserResponseOption) []*UserResponse {
result := make([]*UserResponse, 0, len(users))
for _, user := range users {
result = append(result, ConvertUserToResponse(user))
result = append(result, ConvertUserToResponse(user, opts...))
}
return result
}
// ConvertUsersToResponseWithMutualFollow 将User列表转换为响应列表包含双向关注状态
// followingStatusMap: key是用户IDvalue是[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列表转换为响应列表包含双向关注状态和实时计算的帖子数量
// ConvertUsersToResponseWithMutualFollowAndPostsCount 将User列表转换为响应列表包含双向关注状态和帖子数量
// followingStatusMap: key是用户IDvalue是[isFollowing, isFollowingMe]
// postsCountMap: key是用户IDvalue是帖子数量
func ConvertUsersToResponseWithMutualFollowAndPostsCount(users []*model.User, followingStatusMap map[string][2]bool, postsCountMap map[string]int64) []*UserResponse {
@@ -280,17 +156,15 @@ func ConvertUsersToResponseWithMutualFollowAndPostsCount(users []*model.User, fo
for _, user := range users {
status, hasStatus := followingStatusMap[user.ID]
postsCount, hasPostsCount := postsCountMap[user.ID]
// 如果没有帖子数量,使用数据库中的值
if !hasPostsCount {
postsCount = int64(user.PostsCount)
}
var opts []UserResponseOption
if hasStatus {
result = append(result, ConvertUserToResponseWithMutualFollowAndPostsCount(user, status[0], status[1], int(postsCount)))
} else {
result = append(result, ConvertUserToResponseWithPostsCount(user, int(postsCount)))
opts = append(opts, WithFollowing(status[0], status[1]))
}
opts = append(opts, WithPostsCount(int(postsCount)))
result = append(result, ConvertUserToResponse(user, opts...))
}
return result
}
}

View File

@@ -316,8 +316,7 @@ func (h *MessageHandler) SendMessage(c *gin.Context) {
return
}
// 直接使用 segments
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, req.Segments, req.ReplyToID)
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, req.Segments, req.ReplyToID, req.ClientMsgID)
if err != nil {
response.BadRequest(c, err.Error())
return
@@ -359,7 +358,7 @@ func (h *MessageHandler) HandleSendMessage(c *gin.Context) {
}
// 发送消息
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, params.Segments, params.ReplyToID)
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, params.Segments, params.ReplyToID, params.ClientMsgID)
if err != nil {
response.BadRequest(c, err.Error())
return

View File

@@ -164,7 +164,7 @@ func (h *PostHandler) GetByID(c *gin.Context) {
if currentUserID != "" {
_, isFollowing, isFollowingMe, err := h.userService.GetUserByIDWithMutualFollowStatus(c.Request.Context(), post.UserID, currentUserID)
if err == nil {
authorWithFollowStatus = dto.ConvertUserToResponseWithMutualFollow(post.User, isFollowing, isFollowingMe)
authorWithFollowStatus = dto.ConvertUserToResponse(post.User, dto.WithFollowing(isFollowing, isFollowingMe))
} else {
authorWithFollowStatus = dto.ConvertUserToResponse(post.User)
}

View File

@@ -307,7 +307,7 @@ func (h *UserHandler) GetUserByID(c *gin.Context) {
}
// 转换为响应格式,包含双向关注状态和实时计算的帖子数量
userResponse := dto.ConvertUserToResponseWithMutualFollowAndPostsCount(user, isFollowing, isFollowingMe, int(postsCount))
userResponse := dto.ConvertUserToResponse(user, dto.WithFollowing(isFollowing, isFollowingMe), dto.WithPostsCount(int(postsCount)))
response.Success(c, userResponse)
}

View File

@@ -147,7 +147,17 @@ func (h *WSHandler) HandleWebSocket(c *gin.Context) {
}
// 4. 注册客户端
replayEvents := h.wsHub.Register(client)
replayEvents, regErr := h.wsHub.Register(client)
if regErr != nil {
zap.L().Warn("WebSocket registration rejected",
zap.String("user_id", userID),
zap.Error(regErr),
)
conn.WriteMessage(websocket.CloseMessage,
[]byte(`{"type":"error","payload":{"code":"connection_limit","message":"too many connections"}}`))
conn.Close()
return
}
defer h.wsHub.Unregister(client)
zap.L().Info("WebSocket client connected",
@@ -264,6 +274,11 @@ func (h *WSHandler) writePump(conn *websocket.Conn, client *ws.Client) {
w, err := conn.NextWriter(websocket.TextMessage)
if err != nil {
zap.L().Warn("WebSocket write error (NextWriter)",
zap.String("user_id", client.UserID),
zap.Uint64("client_id", client.ID),
zap.Error(err),
)
return
}
w.Write(message)
@@ -276,6 +291,11 @@ func (h *WSHandler) writePump(conn *websocket.Conn, client *ws.Client) {
}
if err := w.Close(); err != nil {
zap.L().Warn("WebSocket write error (Close)",
zap.String("user_id", client.UserID),
zap.Uint64("client_id", client.ID),
zap.Error(err),
)
return
}
case <-ticker.C:
@@ -349,6 +369,7 @@ func (h *WSHandler) handleChat(ctx context.Context, client *ws.Client, payload j
DetailType string `json:"detail_type"`
Segments model.MessageSegments `json:"segments"`
ReplyToID *string `json:"reply_to_id,omitempty"`
ClientMsgID string `json:"client_msg_id,omitempty"`
}
if err := json.Unmarshal(payload, &req); err != nil {
@@ -367,7 +388,7 @@ func (h *WSHandler) handleChat(ctx context.Context, client *ws.Client, payload j
}
// 发送消息
message, err := h.chatService.SendMessage(ctx, client.UserID, req.ConversationID, req.Segments, req.ReplyToID)
message, err := h.chatService.SendMessage(ctx, client.UserID, req.ConversationID, req.Segments, req.ReplyToID, req.ClientMsgID)
if err != nil {
h.wsHub.SendError(client, "send_failed", err.Error())
return

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -81,9 +80,7 @@ type AuditLog struct {
// BeforeCreate 创建前生成UUID
func (al *AuditLog) BeforeCreate(tx *gorm.DB) error {
if al.ID == "" {
al.ID = uuid.New().String()
}
SetUUIDIfEmpty(&al.ID)
return nil
}

View File

@@ -1,12 +1,9 @@
package model
import (
"strconv"
"time"
"gorm.io/gorm"
"with_you/internal/pkg/utils"
)
// CallType 通话类型
@@ -60,14 +57,7 @@ type CallSession struct {
// BeforeCreate 创建前生成雪花算法ID
func (c *CallSession) BeforeCreate(tx *gorm.DB) error {
if c.ID == "" {
id, err := utils.GetSnowflake().GenerateID()
if err != nil {
return err
}
c.ID = strconv.FormatInt(id, 10)
}
return nil
return SetSnowflakeStringID(&c.ID)
}
func (CallSession) TableName() string {

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -20,9 +19,7 @@ type Channel struct {
}
func (c *Channel) BeforeCreate(tx *gorm.DB) error {
if c.ID == "" {
c.ID = uuid.New().String()
}
SetUUIDIfEmpty(&c.ID)
return nil
}

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -53,9 +52,7 @@ type Comment struct {
// BeforeCreate 创建前生成UUID
func (c *Comment) BeforeCreate(tx *gorm.DB) error {
if c.ID == "" {
c.ID = uuid.New().String()
}
SetUUIDIfEmpty(&c.ID)
return nil
}
@@ -73,9 +70,7 @@ type CommentLike struct {
// BeforeCreate 创建前生成UUID
func (cl *CommentLike) BeforeCreate(tx *gorm.DB) error {
if cl.ID == "" {
cl.ID = uuid.New().String()
}
SetUUIDIfEmpty(&cl.ID)
return nil
}

View File

@@ -1,12 +1,9 @@
package model
import (
"strconv"
"time"
"gorm.io/gorm"
"with_you/internal/pkg/utils"
)
// ConversationType 会话类型
@@ -36,14 +33,7 @@ type Conversation struct {
// BeforeCreate 创建前生成雪花算法ID
func (c *Conversation) BeforeCreate(tx *gorm.DB) error {
if c.ID == "" {
id, err := utils.GetSnowflake().GenerateID()
if err != nil {
return err
}
c.ID = strconv.FormatInt(id, 10)
}
return nil
return SetSnowflakeStringID(&c.ID)
}
func (Conversation) TableName() string {

View File

@@ -4,8 +4,6 @@ import (
"time"
"gorm.io/gorm"
"with_you/internal/pkg/utils"
)
// DeviceType 设备类型
@@ -41,14 +39,7 @@ type DeviceToken struct {
// BeforeCreate 创建前生成雪花算法ID
func (d *DeviceToken) BeforeCreate(tx *gorm.DB) error {
if d.ID == 0 {
id, err := utils.GetSnowflake().GenerateID()
if err != nil {
return err
}
d.ID = id
}
return nil
return SetSnowflakeInt64ID(&d.ID)
}
func (DeviceToken) TableName() string {

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -17,9 +16,7 @@ type Favorite struct {
// BeforeCreate 创建前生成UUID
func (f *Favorite) BeforeCreate(tx *gorm.DB) error {
if f.ID == "" {
f.ID = uuid.New().String()
}
SetUUIDIfEmpty(&f.ID)
return nil
}

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -17,9 +16,7 @@ type Follow struct {
// BeforeCreate 创建前生成UUID
func (f *Follow) BeforeCreate(tx *gorm.DB) error {
if f.ID == "" {
f.ID = uuid.New().String()
}
SetUUIDIfEmpty(&f.ID)
return nil
}

View File

@@ -4,8 +4,6 @@ import (
"strconv"
"time"
"with_you/internal/pkg/utils"
"gorm.io/gorm"
)
@@ -35,14 +33,7 @@ type Group struct {
// BeforeCreate 创建前生成雪花算法ID
func (g *Group) BeforeCreate(tx *gorm.DB) error {
if g.ID == "" {
id, err := utils.GetSnowflake().GenerateID()
if err != nil {
return err
}
g.ID = strconv.FormatInt(id, 10)
}
return nil
return SetSnowflakeStringID(&g.ID)
}
// GetIDInt 获取数字类型的ID用于比较

View File

@@ -1,11 +1,8 @@
package model
import (
"strconv"
"time"
"with_you/internal/pkg/utils"
"gorm.io/gorm"
)
@@ -22,14 +19,7 @@ type GroupAnnouncement struct {
// BeforeCreate 创建前生成雪花算法ID
func (ga *GroupAnnouncement) BeforeCreate(tx *gorm.DB) error {
if ga.ID == "" {
id, err := utils.GetSnowflake().GenerateID()
if err != nil {
return err
}
ga.ID = strconv.FormatInt(id, 10)
}
return nil
return SetSnowflakeStringID(&ga.ID)
}
// TableName 指定表名

View File

@@ -1,11 +1,8 @@
package model
import (
"strconv"
"time"
"with_you/internal/pkg/utils"
"gorm.io/gorm"
)
@@ -44,14 +41,7 @@ type GroupJoinRequest struct {
}
func (r *GroupJoinRequest) BeforeCreate(tx *gorm.DB) error {
if r.ID == "" {
id, err := utils.GetSnowflake().GenerateID()
if err != nil {
return err
}
r.ID = strconv.FormatInt(id, 10)
}
return nil
return SetSnowflakeStringID(&r.ID)
}
func (GroupJoinRequest) TableName() string {

View File

@@ -1,11 +1,8 @@
package model
import (
"strconv"
"time"
"with_you/internal/pkg/utils"
"gorm.io/gorm"
)
@@ -24,14 +21,7 @@ type GroupMember struct {
// BeforeCreate 创建前生成雪花算法ID
func (gm *GroupMember) BeforeCreate(tx *gorm.DB) error {
if gm.ID == "" {
id, err := utils.GetSnowflake().GenerateID()
if err != nil {
return err
}
gm.ID = strconv.FormatInt(id, 10)
}
return nil
return SetSnowflakeStringID(&gm.ID)
}
// TableName 指定表名

View File

@@ -0,0 +1,56 @@
package model
import (
"strconv"
"github.com/google/uuid"
"gorm.io/gorm"
"with_you/internal/pkg/utils"
)
func SetUUIDIfEmpty(id *string) {
if *id == "" {
*id = uuid.New().String()
}
}
func SetSnowflakeStringID(id *string) error {
if *id == "" {
sid, err := utils.GetSnowflake().GenerateID()
if err != nil {
return err
}
*id = strconv.FormatInt(sid, 10)
}
return nil
}
func SetSnowflakeInt64ID(id *int64) error {
if *id == 0 {
sid, err := utils.GetSnowflake().GenerateID()
if err != nil {
return err
}
*id = sid
}
return nil
}
func GenerateUUID() string {
return uuid.New().String()
}
func GenerateSnowflakeString() (string, error) {
id, err := utils.GetSnowflake().GenerateID()
if err != nil {
return "", err
}
return strconv.FormatInt(id, 10), nil
}
func GenerateSnowflakeInt64() (int64, error) {
return utils.GetSnowflake().GenerateID()
}
func nop(_ *gorm.DB) error { return nil }

View File

@@ -0,0 +1,28 @@
package model
import (
"database/sql/driver"
"encoding/json"
)
// ScanJSON scans a database value (nil, []byte, or string) into any JSON-unmarshalable pointer.
func ScanJSON[T any](ptr *T, value any) error {
if value == nil {
return nil
}
var data []byte
switch v := value.(type) {
case []byte:
data = v
case string:
data = []byte(v)
default:
return nil
}
return json.Unmarshal(data, ptr)
}
// ValueJSON marshals any value to JSON for database storage.
func ValueJSON[T any](val T) (driver.Value, error) {
return json.Marshal(val)
}

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -17,9 +16,7 @@ type PostLike struct {
// BeforeCreate 创建前生成UUID
func (pl *PostLike) BeforeCreate(tx *gorm.DB) error {
if pl.ID == "" {
pl.ID = uuid.New().String()
}
SetUUIDIfEmpty(&pl.ID)
return nil
}

View File

@@ -2,10 +2,8 @@ package model
import (
"database/sql/driver"
"encoding/json"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -44,9 +42,7 @@ type MaterialSubject struct {
// BeforeCreate 创建前生成UUID
func (s *MaterialSubject) BeforeCreate(tx *gorm.DB) error {
if s.ID == "" {
s.ID = uuid.New().String()
}
SetUUIDIfEmpty(&s.ID)
return nil
}
@@ -62,7 +58,7 @@ func (a StringArray) Value() (driver.Value, error) {
if a == nil {
return nil, nil
}
return json.Marshal(a)
return ValueJSON(a)
}
// Scan 实现 sql.Scanner 接口
@@ -71,16 +67,7 @@ func (a *StringArray) Scan(value any) error {
*a = nil
return nil
}
var data []byte
switch v := value.(type) {
case []byte:
data = v
case string:
data = []byte(v)
default:
return nil
}
return json.Unmarshal(data, a)
return ScanJSON(a, value)
}
// MaterialFile 文件资料
@@ -107,9 +94,7 @@ type MaterialFile struct {
// BeforeCreate 创建前生成UUID
func (f *MaterialFile) BeforeCreate(tx *gorm.DB) error {
if f.ID == "" {
f.ID = uuid.New().String()
}
SetUUIDIfEmpty(&f.ID)
return nil
}

View File

@@ -3,15 +3,12 @@
import (
"database/sql/driver"
"encoding/json"
"errors"
"strconv"
"sync"
"time"
"gorm.io/gorm"
"with_you/internal/pkg/crypto"
"with_you/internal/pkg/utils"
)
// 系统消息相关常量
@@ -93,24 +90,12 @@ type ExtraData struct {
// Value 实现driver.Valuer接口用于数据库存储
func (e ExtraData) Value() (driver.Value, error) {
return json.Marshal(e)
return ValueJSON(e)
}
// Scan 实现sql.Scanner接口用于数据库读取
func (e *ExtraData) Scan(value any) error {
if value == nil {
return nil
}
var data []byte
switch v := value.(type) {
case []byte:
data = v
case string:
data = []byte(v)
default:
return errors.New("type assertion to []byte or string failed")
}
return json.Unmarshal(data, e)
return ScanJSON(e, value)
}
// MessageSegmentData 单个消息段的数据
@@ -127,7 +112,7 @@ type MessageSegments []MessageSegment
// Value 实现driver.Valuer接口用于数据库存储
func (s MessageSegments) Value() (driver.Value, error) {
return json.Marshal(s)
return ValueJSON(s)
}
// Scan 实现sql.Scanner接口用于数据库读取
@@ -136,16 +121,7 @@ func (s *MessageSegments) Scan(value any) error {
*s = nil
return nil
}
var data []byte
switch v := value.(type) {
case []byte:
data = v
case string:
data = []byte(v)
default:
return errors.New("type assertion to []byte or string failed")
}
return json.Unmarshal(data, s)
return ScanJSON(s, value)
}
// Message 消息实体
@@ -188,12 +164,8 @@ func (m *Message) SenderIDStr() string {
// BeforeCreate 创建前生成雪花算法ID并加密消息内容
func (m *Message) BeforeCreate(tx *gorm.DB) error {
if m.ID == "" {
id, err := utils.GetSnowflake().GenerateID()
if err != nil {
return err
}
m.ID = strconv.FormatInt(id, 10)
if err := SetSnowflakeStringID(&m.ID); err != nil {
return err
}
// 加密消息内容

View File

@@ -1,19 +0,0 @@
package model
import (
"time"
)
// MessageRead 消息已读状态
// 记录每个用户在每个会话中的已读位置
type MessageRead struct {
ID uint `gorm:"primaryKey" json:"id"`
ConversationID int64 `gorm:"uniqueIndex:idx_conversation_user;not null" json:"conversation_id"`
UserID uint `gorm:"uniqueIndex:idx_conversation_user;not null" json:"user_id"`
LastReadSeq int64 `gorm:"not null" json:"last_read_seq"` // 已读到的seq位置
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
func (MessageRead) TableName() string {
return "message_reads"
}

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -42,9 +41,7 @@ type Notification struct {
// BeforeCreate 创建前生成UUID
func (n *Notification) BeforeCreate(tx *gorm.DB) error {
if n.ID == "" {
n.ID = uuid.New().String()
}
SetUUIDIfEmpty(&n.ID)
return nil
}

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -72,9 +71,7 @@ type OperationLog struct {
// BeforeCreate 创建前生成TraceID
func (ol *OperationLog) BeforeCreate(tx *gorm.DB) error {
if ol.TraceID == "" {
ol.TraceID = uuid.New().String()
}
SetUUIDIfEmpty(&ol.TraceID)
if ol.OccurredAt.IsZero() {
ol.OccurredAt = time.Now()
}

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -66,9 +65,7 @@ type Post struct {
// BeforeCreate 创建前生成UUID
func (p *Post) BeforeCreate(tx *gorm.DB) error {
if p.ID == "" {
p.ID = uuid.New().String()
}
SetUUIDIfEmpty(&p.ID)
return nil
}
@@ -94,9 +91,7 @@ type PostImage struct {
// BeforeCreate 创建前生成UUID
func (pi *PostImage) BeforeCreate(tx *gorm.DB) error {
if pi.ID == "" {
pi.ID = uuid.New().String()
}
SetUUIDIfEmpty(&pi.ID)
return nil
}

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -29,9 +28,7 @@ type PostReference struct {
}
func (pr *PostReference) BeforeCreate(tx *gorm.DB) error {
if pr.ID == "" {
pr.ID = uuid.New().String()
}
SetUUIDIfEmpty(&pr.ID)
return nil
}

View File

@@ -4,8 +4,6 @@ import (
"time"
"gorm.io/gorm"
"with_you/internal/pkg/utils"
)
// PushChannel 推送通道类型
@@ -66,14 +64,7 @@ type PushRecord struct {
// BeforeCreate 创建前生成雪花算法ID
func (r *PushRecord) BeforeCreate(tx *gorm.DB) error {
if r.ID == 0 {
id, err := utils.GetSnowflake().GenerateID()
if err != nil {
return err
}
r.ID = id
}
return nil
return SetSnowflakeInt64ID(&r.ID)
}
func (PushRecord) TableName() string {

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -60,9 +59,7 @@ type Report struct {
// BeforeCreate 创建前生成UUID
func (r *Report) BeforeCreate(tx *gorm.DB) error {
if r.ID == "" {
r.ID = uuid.New().String()
}
SetUUIDIfEmpty(&r.ID)
return nil
}

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -24,9 +23,7 @@ type ScheduleCourse struct {
}
func (s *ScheduleCourse) BeforeCreate(tx *gorm.DB) error {
if s.ID == "" {
s.ID = uuid.New().String()
}
SetUUIDIfEmpty(&s.ID)
return nil
}

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -46,9 +45,7 @@ type SensitiveWord struct {
// BeforeCreate 创建前生成UUID
func (sw *SensitiveWord) BeforeCreate(tx *gorm.DB) error {
if sw.ID == "" {
sw.ID = uuid.New().String()
}
SetUUIDIfEmpty(&sw.ID)
return nil
}

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -26,8 +25,6 @@ func (UserSticker) TableName() string {
// BeforeCreate 创建前生成UUID
func (s *UserSticker) BeforeCreate(tx *gorm.DB) error {
if s.ID == "" {
s.ID = uuid.New().String()
}
SetUUIDIfEmpty(&s.ID)
return nil
}

View File

@@ -2,13 +2,9 @@
import (
"database/sql/driver"
"encoding/json"
"errors"
"time"
"gorm.io/gorm"
"with_you/internal/pkg/utils"
)
// SystemNotificationType 系统通知类型
@@ -71,24 +67,12 @@ type SystemNotificationExtra struct {
// Value 实现driver.Valuer接口
func (e SystemNotificationExtra) Value() (driver.Value, error) {
return json.Marshal(e)
return ValueJSON(e)
}
// Scan 实现sql.Scanner接口
func (e *SystemNotificationExtra) Scan(value any) error {
if value == nil {
return nil
}
var data []byte
switch v := value.(type) {
case []byte:
data = v
case string:
data = []byte(v)
default:
return errors.New("type assertion to []byte or string failed")
}
return json.Unmarshal(data, e)
return ScanJSON(e, value)
}
// SystemNotification 系统通知(独立表,与消息完全分离)
@@ -113,14 +97,7 @@ type SystemNotification struct {
// BeforeCreate 创建前生成雪花算法ID
func (n *SystemNotification) BeforeCreate(tx *gorm.DB) error {
if n.ID == 0 {
id, err := utils.GetSnowflake().GenerateID()
if err != nil {
return err
}
n.ID = id
}
return nil
return SetSnowflakeInt64ID(&n.ID)
}
// TableName 指定表名

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -57,9 +56,7 @@ type TradeItem struct {
}
func (t *TradeItem) BeforeCreate(tx *gorm.DB) error {
if t.ID == "" {
t.ID = uuid.New().String()
}
SetUUIDIfEmpty(&t.ID)
return nil
}
@@ -81,9 +78,7 @@ type TradeImage struct {
}
func (ti *TradeImage) BeforeCreate(tx *gorm.DB) error {
if ti.ID == "" {
ti.ID = uuid.New().String()
}
SetUUIDIfEmpty(&ti.ID)
return nil
}
@@ -99,9 +94,7 @@ type TradeFavorite struct {
}
func (f *TradeFavorite) BeforeCreate(tx *gorm.DB) error {
if f.ID == "" {
f.ID = uuid.New().String()
}
SetUUIDIfEmpty(&f.ID)
return nil
}

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -106,9 +105,7 @@ type User struct {
// BeforeCreate 创建前生成UUID
func (u *User) BeforeCreate(tx *gorm.DB) error {
if u.ID == "" {
u.ID = uuid.New().String()
}
SetUUIDIfEmpty(&u.ID)
return nil
}

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -16,9 +15,7 @@ type UserBlock struct {
}
func (b *UserBlock) BeforeCreate(tx *gorm.DB) error {
if b.ID == "" {
b.ID = uuid.New().String()
}
SetUUIDIfEmpty(&b.ID)
return nil
}

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -40,9 +39,7 @@ type UserProfileAudit struct {
}
func (a *UserProfileAudit) BeforeCreate(tx *gorm.DB) error {
if a.ID == "" {
a.ID = uuid.New().String()
}
SetUUIDIfEmpty(&a.ID)
return nil
}

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -29,9 +28,7 @@ type VerificationRecord struct {
}
func (v *VerificationRecord) BeforeCreate(tx *gorm.DB) error {
if v.ID == "" {
v.ID = uuid.New().String()
}
SetUUIDIfEmpty(&v.ID)
return nil
}

View File

@@ -3,7 +3,6 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -20,9 +19,7 @@ type VoteOption struct {
// BeforeCreate 创建前生成UUID
func (vo *VoteOption) BeforeCreate(tx *gorm.DB) error {
if vo.ID == "" {
vo.ID = uuid.New().String()
}
SetUUIDIfEmpty(&vo.ID)
return nil
}
@@ -41,9 +38,7 @@ type UserVote struct {
// BeforeCreate 创建前生成UUID
func (uv *UserVote) BeforeCreate(tx *gorm.DB) error {
if uv.ID == "" {
uv.ID = uuid.New().String()
}
SetUUIDIfEmpty(&uv.ID)
return nil
}

View File

@@ -0,0 +1,136 @@
package circuitbreaker
import (
"sync"
"time"
"go.uber.org/zap"
)
type State int
const (
StateClosed State = iota
StateOpen
StateHalfOpen
)
type Config struct {
FailureThreshold int // 连续失败次数阈值,超过则开启熔断
SuccessThreshold int // 半开状态下连续成功次数,恢复到关闭
Timeout time.Duration // 熔断开启后等待多久进入半开
Name string // 熔断器名称,用于日志
}
func DefaultConfig(name string) Config {
return Config{
FailureThreshold: 5,
SuccessThreshold: 3,
Timeout: 30 * time.Second,
Name: name,
}
}
type Breaker struct {
cfg Config
mu sync.Mutex
state State
fails int
wins int
openAt time.Time
}
func New(cfg Config) *Breaker {
return &Breaker{cfg: cfg}
}
func (b *Breaker) Allow() bool {
b.mu.Lock()
defer b.mu.Unlock()
switch b.state {
case StateClosed:
return true
case StateOpen:
if time.Since(b.openAt) > b.cfg.Timeout {
b.state = StateHalfOpen
b.wins = 0
return true
}
return false
case StateHalfOpen:
return true
default:
return true
}
}
func (b *Breaker) RecordSuccess() {
b.mu.Lock()
defer b.mu.Unlock()
b.fails = 0
if b.state == StateHalfOpen {
b.wins++
if b.wins >= b.cfg.SuccessThreshold {
b.state = StateClosed
b.wins = 0
zap.L().Info("circuit breaker closed",
zap.String("breaker", b.cfg.Name),
)
}
}
}
func (b *Breaker) RecordFailure() {
b.mu.Lock()
defer b.mu.Unlock()
b.fails++
if b.state == StateHalfOpen {
b.state = StateOpen
b.openAt = time.Now()
b.wins = 0
zap.L().Warn("circuit breaker re-opened from half-open",
zap.String("breaker", b.cfg.Name),
)
return
}
if b.fails >= b.cfg.FailureThreshold {
b.state = StateOpen
b.openAt = time.Now()
zap.L().Warn("circuit breaker opened",
zap.String("breaker", b.cfg.Name),
zap.Int("consecutive_failures", b.fails),
)
}
}
func (b *Breaker) State() State {
b.mu.Lock()
defer b.mu.Unlock()
return b.state
}
func (b *Breaker) Execute(fn func() error) error {
if !b.Allow() {
zap.L().Debug("circuit breaker rejected request",
zap.String("breaker", b.cfg.Name),
)
return &OpenError{Name: b.cfg.Name}
}
if err := fn(); err != nil {
b.RecordFailure()
return err
}
b.RecordSuccess()
return nil
}
type OpenError struct {
Name string
}
func (e *OpenError) Error() string {
return "circuit breaker '" + e.Name + "' is open"
}

View File

@@ -13,6 +13,8 @@ import (
"strconv"
"time"
"with_you/internal/pkg/circuitbreaker"
"go.uber.org/zap"
)
@@ -29,6 +31,7 @@ type Client struct {
httpClient *http.Client
logger *zap.Logger
lastRateLimit *RateLimitInfo
breaker *circuitbreaker.Breaker
}
func NewClient(appKey, masterSecret string, production bool, logger *zap.Logger) *Client {
@@ -48,6 +51,12 @@ func NewClient(appKey, masterSecret string, production bool, logger *zap.Logger)
production: production,
httpClient: &http.Client{Timeout: 30 * time.Second, Transport: transport},
logger: logger,
breaker: circuitbreaker.New(circuitbreaker.Config{
FailureThreshold: 5,
SuccessThreshold: 3,
Timeout: 30 * time.Second,
Name: "jpush",
}),
}
}
@@ -259,7 +268,15 @@ func (c *Client) PushByRegistrationIDs(registrationIDs []string, notification *N
},
}
result, _, err := c.Push(payload)
var result *PushResponse
err := c.breaker.Execute(func() error {
resp, _, pushErr := c.Push(payload)
if pushErr != nil {
return pushErr
}
result = resp
return nil
})
if err != nil {
c.logger.Error("jpush push by registration_ids failed",
zap.Int("count", len(registrationIDs)),

View File

@@ -15,13 +15,6 @@ type Response struct {
Data any `json:"data,omitempty"`
}
// ResponseSnakeCase 统一响应结构(snake_case)
type ResponseSnakeCase struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
// Success 成功响应
func Success(c *gin.Context, data any) {
c.JSON(http.StatusOK, Response{

View File

@@ -2,6 +2,7 @@ package ws
import (
"encoding/json"
"errors"
"sync"
"sync/atomic"
"time"
@@ -10,8 +11,12 @@ import (
)
const (
defaultUserBufferSize = 128
maxReplayEvents = 200
defaultUserBufferSize = 128
maxReplayEvents = 200
groupPushConcurrency = 64
groupPushQueueSize = 512
maxTotalConnections = 100000
maxConnectionsPerUser = 10
)
// Event 表示一个WebSocket事件
@@ -31,6 +36,9 @@ type Client struct {
Quit chan struct{}
}
// ErrConnectionLimit 连接数限制错误
var ErrConnectionLimit = errors.New("connection limit reached")
// Message 表示WebSocket消息
type Message struct {
Type string `json:"type"`
@@ -73,14 +81,15 @@ type ConnectHandler func(userID string, isFirstConnection bool)
// Hub WebSocket连接管理器
type Hub struct {
seq uint64
seq uint64
connCount atomic.Int64
mu sync.RWMutex
clients map[string]map[uint64]*Client // userID -> clientID -> Client
history map[string][]Event // userID -> []Event (用于断线重连历史回放)
subscribers map[string]map[uint64]*subscriber // userID -> subscriberID -> subscriber
disconnectHandlers []DisconnectHandler // 断开连接事件处理器列表
connectHandlers []ConnectHandler // 连接事件处理器列表
clients map[string]map[uint64]*Client
history map[string][]Event
subscribers map[string]map[uint64]*subscriber
disconnectHandlers []DisconnectHandler
connectHandlers []ConnectHandler
}
// NewHub 创建WebSocket Hub
@@ -99,11 +108,19 @@ func (h *Hub) NextID() uint64 {
return atomic.AddUint64(&h.seq, 1)
}
// Register 注册客户端连接
func (h *Hub) Register(client *Client) []Event {
// Register 注册客户端连接,返回回放事件和可能的错误(连接数超限)
func (h *Hub) Register(client *Client) ([]Event, error) {
if h.connCount.Load() >= int64(maxTotalConnections) {
return nil, ErrConnectionLimit
}
h.mu.Lock()
// 检查是否是该用户的第一个连接(之前完全离线)
if len(h.clients[client.UserID]) >= maxConnectionsPerUser {
h.mu.Unlock()
return nil, ErrConnectionLimit
}
_, existed := h.clients[client.UserID]
isFirstConnection := !existed
@@ -111,8 +128,8 @@ func (h *Hub) Register(client *Client) []Event {
h.clients[client.UserID] = make(map[uint64]*Client)
}
h.clients[client.UserID][client.ID] = client
h.connCount.Add(1)
// 获取历史事件用于回放
replay := make([]Event, 0)
for _, e := range h.history[client.UserID] {
replay = append(replay, e)
@@ -122,15 +139,14 @@ func (h *Hub) Register(client *Client) []Event {
zap.String("user_id", client.UserID),
zap.Uint64("client_id", client.ID),
zap.Int("total_clients", len(h.clients[client.UserID])),
zap.Int64("total_connections", h.connCount.Load()),
zap.Bool("is_first_connection", isFirstConnection),
)
// 复制处理器列表避免锁竞争
handlers := make([]ConnectHandler, len(h.connectHandlers))
copy(handlers, h.connectHandlers)
h.mu.Unlock()
// 异步调用连接处理器,避免阻塞 Register
if len(handlers) > 0 {
go func() {
for _, handler := range handlers {
@@ -139,7 +155,7 @@ func (h *Hub) Register(client *Client) []Event {
}()
}
return replay
return replay, nil
}
// Unregister 注销客户端连接
@@ -151,6 +167,7 @@ func (h *Hub) Unregister(client *Client) {
if _, exists := userClients[client.ID]; exists {
delete(userClients, client.ID)
close(client.Quit)
h.connCount.Add(-1)
remainingCount = len(userClients)
if remainingCount == 0 {
@@ -187,6 +204,20 @@ func (h *Hub) HasClients(userID string) bool {
return len(h.clients[userID]) > 0
}
// FilterOnline 批量检查用户在线状态,一次加锁返回在线/离线列表
func (h *Hub) FilterOnline(userIDs []string) (online []string, offline []string) {
h.mu.RLock()
defer h.mu.RUnlock()
for _, uid := range userIDs {
if len(h.clients[uid]) > 0 {
online = append(online, uid)
} else {
offline = append(offline, uid)
}
}
return
}
// OnDisconnect 注册断开连接事件处理器
// 当用户断开 WebSocket 连接时,会调用所有注册的处理器
// 处理器会收到用户ID和该用户剩余的连接数
@@ -224,10 +255,65 @@ func (h *Hub) PublishToUser(userID string, eventType string, payload any) Event
return ev
}
// PublishToUsers 向多个用户发送事件
// PublishToUsers 向多个用户发送事件(共享一次序列化)
func (h *Hub) PublishToUsers(userIDs []string, eventType string, payload any) {
if len(userIDs) == 0 {
return
}
ev := Event{
ID: h.NextID(),
Type: eventType,
TS: time.Now().UnixMilli(),
Payload: payload,
}
msg := ResponseMessage{
EventID: ev.ID,
Type: eventType,
TS: ev.TS,
Payload: payload,
}
data, err := json.Marshal(msg)
if err != nil {
zap.L().Error("Failed to marshal WebSocket message",
zap.String("event_type", eventType),
zap.Error(err),
)
return
}
for _, uid := range userIDs {
h.PublishToUser(uid, eventType, payload)
h.publishPreMarshaled(uid, ev, data)
}
}
// publishPreMarshaled 使用预序列化的数据发送事件
func (h *Hub) publishPreMarshaled(userID string, ev Event, data []byte) {
h.mu.Lock()
history := append(h.history[userID], ev)
if len(history) > maxReplayEvents {
history = history[len(history)-maxReplayEvents:]
}
h.history[userID] = history
targets := make([]*Client, 0, len(h.clients[userID]))
for _, c := range h.clients[userID] {
targets = append(targets, c)
}
h.mu.Unlock()
for _, c := range targets {
select {
case <-c.Quit:
case c.Send <- data:
default:
zap.L().Warn("WebSocket client buffer full, closing slow client",
zap.String("user_id", userID),
zap.Uint64("client_id", c.ID),
)
close(c.Quit)
}
}
}
@@ -338,19 +424,16 @@ func (h *Hub) publish(userID string, ev Event) {
return
}
// 非阻塞发送,慢消费者丢弃消息
for _, c := range targets {
select {
case <-c.Quit:
// 客户端已断开
case c.Send <- data:
// 消息已发送
default:
// 发送缓冲区满,丢弃消息
zap.L().Warn("WebSocket client buffer full, dropping message",
zap.L().Warn("WebSocket client buffer full, closing slow client",
zap.String("user_id", userID),
zap.Uint64("client_id", c.ID),
)
close(c.Quit)
}
}
}
@@ -387,6 +470,81 @@ func (h *Hub) Broadcast(eventType string, payload any) {
h.PublishToUsers(userIDs, eventType, payload)
}
// PublishToUsersConcurrent 并发推送消息给多个用户(适用于群聊等大列表场景)
// 使用有界并发控制,避免 goroutine 爆炸
func (h *Hub) PublishToUsersConcurrent(userIDs []string, eventType string, payload any) {
if len(userIDs) == 0 {
return
}
ev := Event{
ID: h.NextID(),
Type: eventType,
TS: time.Now().UnixMilli(),
Payload: payload,
}
msg := ResponseMessage{
EventID: ev.ID,
Type: eventType,
TS: ev.TS,
Payload: payload,
}
data, err := json.Marshal(msg)
if err != nil {
zap.L().Error("Failed to marshal WebSocket message for batch push",
zap.String("event_type", eventType),
zap.Error(err),
)
return
}
sem := make(chan struct{}, groupPushConcurrency)
var wg sync.WaitGroup
wg.Add(len(userIDs))
for _, uid := range userIDs {
sem <- struct{}{}
go func(userID string) {
defer wg.Done()
defer func() { <-sem }()
h.mu.RLock()
targets := make([]*Client, 0, len(h.clients[userID]))
for _, c := range h.clients[userID] {
targets = append(targets, c)
}
h.mu.RUnlock()
for _, c := range targets {
select {
case <-c.Quit:
case c.Send <- data:
default:
zap.L().Warn("WebSocket client buffer full, closing slow client",
zap.String("user_id", userID),
zap.Uint64("client_id", c.ID),
)
close(c.Quit)
}
}
}(uid)
}
wg.Wait()
// Store history for each user for reconnection replay
h.mu.Lock()
for _, uid := range userIDs {
history := append(h.history[uid], ev)
if len(history) > maxReplayEvents {
history = history[len(history)-maxReplayEvents:]
}
h.history[uid] = history
}
h.mu.Unlock()
}
// GetOnlineUsers 获取在线用户列表
func (h *Hub) GetOnlineUsers() []string {
h.mu.RLock()
@@ -406,6 +564,29 @@ func (h *Hub) GetOnlineCount() int {
return len(h.clients)
}
// GetConnectionCount 获取总连接数
func (h *Hub) GetConnectionCount() int64 {
return h.connCount.Load()
}
// CloseAllConnections 关闭所有WebSocket连接用于优雅关闭
func (h *Hub) CloseAllConnections() {
h.mu.Lock()
defer h.mu.Unlock()
for _, userClients := range h.clients {
for _, c := range userClients {
select {
case <-c.Quit:
default:
close(c.Quit)
}
}
}
h.clients = make(map[string]map[uint64]*Client)
h.connCount.Store(0)
}
// Subscribe 订阅事件
func (h *Hub) Subscribe(userID string, afterID uint64) (chan Event, func(), []Event) {
subID := h.NextID()

View File

@@ -1,6 +1,8 @@
package repository
import (
"errors"
"with_you/internal/model"
"gorm.io/gorm"
@@ -11,6 +13,7 @@ type GroupJoinRequestRepository interface {
GetByFlag(flag string) (*model.GroupJoinRequest, error)
Update(req *model.GroupJoinRequest) error
GetPendingByGroupAndTarget(groupID, targetUserID string, reqType model.GroupJoinRequestType) (*model.GroupJoinRequest, error)
GetPendingByGroupAndTargets(groupID string, targetUserIDs []string, reqType model.GroupJoinRequestType) (map[string]bool, error)
}
type groupJoinRequestRepository struct {
@@ -48,3 +51,25 @@ func (r *groupJoinRequestRepository) GetPendingByGroupAndTarget(groupID, targetU
}
return &req, nil
}
func (r *groupJoinRequestRepository) GetPendingByGroupAndTargets(groupID string, targetUserIDs []string, reqType model.GroupJoinRequestType) (map[string]bool, error) {
result := make(map[string]bool)
if len(targetUserIDs) == 0 {
return result, nil
}
var existingIDs []string
err := r.db.Model(&model.GroupJoinRequest{}).
Where("group_id = ? AND target_user_id IN ? AND request_type = ? AND status = ?",
groupID, targetUserIDs, reqType, model.GroupJoinRequestStatusPending).
Pluck("target_user_id", &existingIDs).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return result, nil
}
return nil, err
}
for _, id := range existingIDs {
result[id] = true
}
return result, nil
}

View File

@@ -21,6 +21,7 @@ type GroupRepository interface {
// 群成员操作
AddMember(member *model.GroupMember) error
BatchAddMembers(groupID string, members []*model.GroupMember) error
GetMember(groupID string, userID string) (*model.GroupMember, error)
GetMembers(groupID string, page, pageSize int) ([]model.GroupMember, int64, error)
UpdateMember(member *model.GroupMember) error
@@ -53,6 +54,9 @@ type GroupRepository interface {
GetUserGroupsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error)
GetMembersByCursor(ctx context.Context, groupID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.GroupMember], error)
GetAnnouncementsByCursor(ctx context.Context, groupID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.GroupAnnouncement], error)
// 批量操作
GetExistingMembers(groupID string, userIDs []string) (map[string]bool, error)
}
// GroupMemberWithUser 群成员带用户信息的结构
@@ -155,12 +159,24 @@ func (r *groupRepository) AddMember(member *model.GroupMember) error {
if err := tx.Create(member).Error; err != nil {
return err
}
// 更新群组成员数量
return tx.Model(&model.Group{}).Where("id = ?", member.GroupID).
Update("member_count", gorm.Expr("member_count + ?", 1)).Error
})
}
func (r *groupRepository) BatchAddMembers(groupID string, members []*model.GroupMember) error {
if len(members) == 0 {
return nil
}
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.CreateInBatches(members, 100).Error; err != nil {
return err
}
return tx.Model(&model.Group{}).Where("id = ?", groupID).
Update("member_count", gorm.Expr("member_count + ?", len(members))).Error
})
}
// GetMember 获取群成员
func (r *groupRepository) GetMember(groupID string, userID string) (*model.GroupMember, error) {
var member model.GroupMember

View File

@@ -10,6 +10,46 @@ import (
"with_you/internal/service"
)
// RouterDeps 路由依赖
type RouterDeps struct {
UserRepo repository.UserRepository
UserHandler *handler.UserHandler
PostHandler *handler.PostHandler
CommentHandler *handler.CommentHandler
MessageHandler *handler.MessageHandler
NotificationHandler *handler.NotificationHandler
UploadHandler *handler.UploadHandler
PushHandler *handler.PushHandler
SystemMessageHandler *handler.SystemMessageHandler
GroupHandler *handler.GroupHandler
StickerHandler *handler.StickerHandler
VoteHandler *handler.VoteHandler
ChannelHandler *handler.ChannelHandler
ScheduleHandler *handler.ScheduleHandler
RoleHandler *handler.RoleHandler
AdminUserHandler *handler.AdminUserHandler
AdminPostHandler *handler.AdminPostHandler
AdminCommentHandler *handler.AdminCommentHandler
AdminGroupHandler *handler.AdminGroupHandler
AdminDashboardHandler *handler.AdminDashboardHandler
AdminLogHandler *handler.AdminLogHandler
QRCodeHandler *handler.QRCodeHandler
MaterialHandler *handler.MaterialHandler
CallHandler *handler.CallHandler
ReportHandler *handler.ReportHandler
AdminReportHandler *handler.AdminReportHandler
VerificationHandler *handler.VerificationHandler
AdminVerificationHandler *handler.AdminVerificationHandler
AdminProfileAuditHandler *handler.AdminProfileAuditHandler
SetupHandler *handler.SetupHandler
TradeHandler *handler.TradeHandler
WSHandler *handler.WSHandler
JWTService *service.JWTService
LogService *service.LogService
ActivityService service.UserActivityService
CasbinService service.CasbinService
}
// Router 路由配置
type Router struct {
engine *gin.Engine
@@ -51,86 +91,47 @@ type Router struct {
}
// New 创建路由
func New(
userRepo repository.UserRepository,
userHandler *handler.UserHandler,
postHandler *handler.PostHandler,
commentHandler *handler.CommentHandler,
messageHandler *handler.MessageHandler,
notificationHandler *handler.NotificationHandler,
uploadHandler *handler.UploadHandler,
jwtService *service.JWTService,
pushHandler *handler.PushHandler,
systemMessageHandler *handler.SystemMessageHandler,
groupHandler *handler.GroupHandler,
stickerHandler *handler.StickerHandler,
voteHandler *handler.VoteHandler,
channelHandler *handler.ChannelHandler,
scheduleHandler *handler.ScheduleHandler,
roleHandler *handler.RoleHandler,
adminUserHandler *handler.AdminUserHandler,
adminPostHandler *handler.AdminPostHandler,
adminCommentHandler *handler.AdminCommentHandler,
adminGroupHandler *handler.AdminGroupHandler,
adminDashboardHandler *handler.AdminDashboardHandler,
adminLogHandler *handler.AdminLogHandler,
qrcodeHandler *handler.QRCodeHandler,
materialHandler *handler.MaterialHandler,
callHandler *handler.CallHandler,
reportHandler *handler.ReportHandler,
adminReportHandler *handler.AdminReportHandler,
verificationHandler *handler.VerificationHandler,
adminVerificationHandler *handler.AdminVerificationHandler,
adminProfileAuditHandler *handler.AdminProfileAuditHandler,
setupHandler *handler.SetupHandler,
tradeHandler *handler.TradeHandler,
logService *service.LogService,
activityService service.UserActivityService,
casbinService service.CasbinService,
wsHandler *handler.WSHandler,
) *Router {
// 设置JWT服务
userHandler.SetJWTService(jwtService)
// 设置活跃统计服务
userHandler.SetActivityService(activityService)
func New(deps RouterDeps) *Router {
deps.UserHandler.SetJWTService(deps.JWTService)
deps.UserHandler.SetActivityService(deps.ActivityService)
r := &Router{
engine: gin.Default(),
userRepo: userRepo,
userHandler: userHandler,
postHandler: postHandler,
commentHandler: commentHandler,
messageHandler: messageHandler,
notificationHandler: notificationHandler,
uploadHandler: uploadHandler,
pushHandler: pushHandler,
systemMessageHandler: systemMessageHandler,
groupHandler: groupHandler,
stickerHandler: stickerHandler,
voteHandler: voteHandler,
channelHandler: channelHandler,
scheduleHandler: scheduleHandler,
roleHandler: roleHandler,
adminUserHandler: adminUserHandler,
adminPostHandler: adminPostHandler,
adminCommentHandler: adminCommentHandler,
adminGroupHandler: adminGroupHandler,
adminDashboardHandler: adminDashboardHandler,
adminLogHandler: adminLogHandler,
qrcodeHandler: qrcodeHandler,
materialHandler: materialHandler,
callHandler: callHandler,
reportHandler: reportHandler,
adminReportHandler: adminReportHandler,
verificationHandler: verificationHandler,
adminVerificationHandler: adminVerificationHandler,
adminProfileAuditHandler: adminProfileAuditHandler,
setupHandler: setupHandler,
tradeHandler: tradeHandler,
logService: logService,
jwtService: jwtService,
casbinService: casbinService,
wsHandler: wsHandler,
userRepo: deps.UserRepo,
userHandler: deps.UserHandler,
postHandler: deps.PostHandler,
commentHandler: deps.CommentHandler,
messageHandler: deps.MessageHandler,
notificationHandler: deps.NotificationHandler,
uploadHandler: deps.UploadHandler,
pushHandler: deps.PushHandler,
systemMessageHandler: deps.SystemMessageHandler,
groupHandler: deps.GroupHandler,
stickerHandler: deps.StickerHandler,
voteHandler: deps.VoteHandler,
channelHandler: deps.ChannelHandler,
scheduleHandler: deps.ScheduleHandler,
roleHandler: deps.RoleHandler,
adminUserHandler: deps.AdminUserHandler,
adminPostHandler: deps.AdminPostHandler,
adminCommentHandler: deps.AdminCommentHandler,
adminGroupHandler: deps.AdminGroupHandler,
adminDashboardHandler: deps.AdminDashboardHandler,
adminLogHandler: deps.AdminLogHandler,
qrcodeHandler: deps.QRCodeHandler,
materialHandler: deps.MaterialHandler,
callHandler: deps.CallHandler,
reportHandler: deps.ReportHandler,
adminReportHandler: deps.AdminReportHandler,
verificationHandler: deps.VerificationHandler,
adminVerificationHandler: deps.AdminVerificationHandler,
adminProfileAuditHandler: deps.AdminProfileAuditHandler,
setupHandler: deps.SetupHandler,
tradeHandler: deps.TradeHandler,
logService: deps.LogService,
jwtService: deps.JWTService,
casbinService: deps.CasbinService,
wsHandler: deps.WSHandler,
}
r.setupRoutes()

View File

@@ -20,6 +20,9 @@ import (
// 撤回消息的时间限制2分钟
const RecallMessageTimeout = 2 * time.Minute
// 消息幂等性 TTL24 小时内同一 client_msg_id 不会重复发送
const messageIdempotentTTL = 24 * time.Hour
// ChatService 聊天服务接口
type ChatService interface {
// 会话管理
@@ -31,7 +34,7 @@ type ChatService interface {
SetConversationNotificationMuted(ctx context.Context, conversationID string, userID string, notificationMuted bool) error
// 消息操作
SendMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string) (*model.Message, error)
SendMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string, clientMsgID string) (*model.Message, error)
GetMessages(ctx context.Context, conversationID string, userID string, page, pageSize int) ([]*model.Message, int64, error)
GetMessagesAfterSeq(ctx context.Context, conversationID string, userID string, afterSeq int64, limit int) ([]*model.Message, error)
GetMessagesBeforeSeq(ctx context.Context, conversationID string, userID string, beforeSeq int64, limit int) ([]*model.Message, error)
@@ -57,15 +60,15 @@ type ChatService interface {
// chatServiceImpl 聊天服务实现
type chatServiceImpl struct {
repo repository.MessageRepository
userRepo repository.UserRepository
sensitive SensitiveService
wsHub *ws.Hub
pushSvc PushService
repo repository.MessageRepository
userRepo repository.UserRepository
sensitive SensitiveService
wsHub *ws.Hub
pushSvc PushService
cache cache.Cache
// 缓存相关字段
conversationCache *cache.ConversationCache
uploadService *UploadService // 校验聊天图片 URL 来源
uploadService *UploadService
}
// NewChatService 创建聊天服务
@@ -96,6 +99,7 @@ func NewChatService(
sensitive: sensitive,
wsHub: wsHub,
pushSvc: pushSvc,
cache: cacheBackend,
conversationCache: conversationCache,
uploadService: uploadService,
}
@@ -245,9 +249,16 @@ func (s *chatServiceImpl) SetConversationNotificationMuted(ctx context.Context,
return nil
}
// SendMessage 发送消息(使用 segments
func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string) (*model.Message, error) {
// 首先验证会话是否存在
func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string, clientMsgID string) (*model.Message, error) {
if clientMsgID != "" && s.cache != nil {
idemKey := cache.MessageIdempotentKey(senderID, clientMsgID)
if cachedMsgID, ok := cache.GetTyped[string](s.cache, idemKey); ok && cachedMsgID != "" {
if existing, err := s.repo.GetMessageByID(cachedMsgID); err == nil && existing != nil {
return existing, nil
}
}
}
conv, err := s.getConversation(ctx, conversationID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -332,7 +343,6 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
s.conversationCache.InvalidateMessagePages(conversationID)
}
// 异步写入缓存
go func() {
if err := s.cacheMessage(context.Background(), conversationID, message); err != nil {
zap.L().Warn("async cache message failed",
@@ -344,12 +354,15 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
}
}()
if clientMsgID != "" && s.cache != nil {
s.cache.Set(cache.MessageIdempotentKey(senderID, clientMsgID), message.ID, messageIdempotentTTL)
}
// 获取会话中的参与者并发送消息
participants, err := s.getParticipants(ctx, conversationID)
if err == nil {
targetIDs := make([]string, 0, len(participants))
for _, p := range participants {
// 私聊场景下,发送者已经从 HTTP 响应拿到消息,避免再通过 WebSocket 回推导致本端重复展示。
if conv.Type == model.ConversationTypePrivate && p.UserID == senderID {
continue
}
@@ -359,18 +372,21 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
if conv.Type == model.ConversationTypeGroup {
detailType = "group"
}
s.publishToUsers(targetIDs, "chat_message", map[string]any{
"detail_type": detailType,
"message": dto.ConvertMessageToResponse(message),
})
if len(targetIDs) > 20 && conv.Type == model.ConversationTypeGroup {
s.wsHub.PublishToUsersConcurrent(targetIDs, "chat_message", map[string]any{
"detail_type": detailType,
"message": dto.ConvertMessageToResponse(message),
})
} else {
s.publishToUsers(targetIDs, "chat_message", map[string]any{
"detail_type": detailType,
"message": dto.ConvertMessageToResponse(message),
})
}
for _, p := range participants {
if p.UserID == senderID {
continue
}
// 失效未读数缓存
if s.conversationCache != nil {
s.conversationCache.InvalidateUnreadCount(p.UserID, conversationID)
}
if totalUnread, uErr := s.repo.GetAllUnreadCount(p.UserID); uErr == nil {
s.publishToUsers([]string{p.UserID}, "conversation_unread", map[string]any{
"conversation_id": conversationID,
@@ -380,14 +396,22 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
}
}
// 失效会话列表缓存
if s.conversationCache != nil {
keys := make([]string, 0, len(participants)*2)
for _, p := range participants {
keys = append(keys,
cache.UnreadDetailKey(p.UserID, conversationID),
cache.UnreadConversationKey(p.UserID),
)
}
if len(keys) > 0 {
s.cache.DeleteBatch(keys)
}
for _, p := range participants {
s.conversationCache.InvalidateConversationList(p.UserID)
}
}
// 对离线用户通过 JPush 推送聊天消息通知
if s.pushSvc != nil && len(participants) > 0 {
sender := ChatMessageSender{ID: senderID}
if senderUser, sErr := s.userRepo.GetByID(senderID); sErr == nil {
@@ -399,26 +423,37 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
if conv.Type == model.ConversationTypeGroup && conv.Group != nil {
convName = conv.Group.Name
}
allTargetIDs := make([]string, 0, len(participants))
for _, p := range participants {
if p.UserID == senderID {
continue
}
go func(userID string, sender ChatMessageSender) {
if pushErr := s.pushSvc.PushChatMessage(context.Background(), userID, conversationID, &sender, convType, convName, message); pushErr != nil {
zap.L().Debug("push chat message skipped or failed",
zap.String("userID", userID),
zap.String("conversationID", conversationID),
zap.Error(pushErr),
)
allTargetIDs = append(allTargetIDs, p.UserID)
}
var offlineTargetIDs []string
if s.wsHub != nil && len(allTargetIDs) > 0 {
_, offlineTargetIDs = s.wsHub.FilterOnline(allTargetIDs)
} else {
offlineTargetIDs = allTargetIDs
}
if len(offlineTargetIDs) > 0 {
go func(ids []string, sender ChatMessageSender) {
for _, uid := range ids {
if pushErr := s.pushSvc.PushChatMessage(context.Background(), uid, conversationID, &sender, convType, convName, message); pushErr != nil {
zap.L().Debug("push chat message failed",
zap.String("userID", uid),
zap.String("conversationID", conversationID),
zap.Error(pushErr),
)
}
}
}(p.UserID, sender)
}(offlineTargetIDs, sender)
}
}
return message, nil
}
// getConversation 获取会话(优先使用缓存)
func (s *chatServiceImpl) getConversation(ctx context.Context, conversationID string) (*model.Conversation, error) {
if s.conversationCache != nil {
return s.conversationCache.GetConversation(ctx, conversationID)

View File

@@ -17,7 +17,7 @@ import (
type TradeService interface {
Create(ctx context.Context, userID string, req dto.CreateTradeItemRequest) (*model.TradeItem, error)
GetByID(ctx context.Context, id string, currentUserID *string) (*dto.TradeItemDetailResponse, error)
GetByID(ctx context.Context, id string, currentUserID *string) (*dto.TradeItemResponse, error)
Update(ctx context.Context, userID string, id string, req dto.UpdateTradeItemRequest) error
Delete(ctx context.Context, userID string, id string) error
UpdateStatus(ctx context.Context, userID string, id string, status model.TradeItemStatus) error
@@ -62,7 +62,7 @@ func (s *tradeService) Create(ctx context.Context, userID string, req dto.Create
return item, nil
}
func (s *tradeService) GetByID(ctx context.Context, id string, currentUserID *string) (*dto.TradeItemDetailResponse, error) {
func (s *tradeService) GetByID(ctx context.Context, id string, currentUserID *string) (*dto.TradeItemResponse, error) {
item, err := s.tradeRepo.GetByID(ctx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -81,7 +81,7 @@ func (s *tradeService) GetByID(ctx context.Context, id string, currentUserID *st
_ = s.tradeRepo.IncrementViews(ctx, id)
return dto.ConvertTradeItemToDetailResponse(item, isFavorited), nil
return dto.ConvertTradeItemToResponse(item, isFavorited), nil
}
func (s *tradeService) Update(ctx context.Context, userID string, id string, req dto.UpdateTradeItemRequest) error {