feat(chat): implement sequence pre-allocation, versioned sync, and push worker
Introduce several performance and synchronization enhancements: - Implement `SeqBufferManager` to allow sequence number pre-allocation via Redis Lua scripts, reducing atomic increment overhead. - Add `PushWorker` to handle asynchronous message pushing using Redis Streams. - Implement incremental conversation synchronization via `ConversationVersionLog` to allow clients to fetch only recent changes. - Add support for Gzip compression in WebSocket communications to reduce bandwidth usage. - Update dependency injection and configuration to support these new components.
This commit is contained in:
@@ -28,6 +28,7 @@ type App struct {
|
||||
Publisher ws.MessagePublisher
|
||||
TaskDispatcher runner.TaskDispatcher
|
||||
Logger *zap.Logger
|
||||
PushWorker *service.PushWorker
|
||||
}
|
||||
|
||||
// NewApp 创建应用程序
|
||||
@@ -41,6 +42,7 @@ func NewApp(
|
||||
publisher ws.MessagePublisher,
|
||||
taskDispatcher runner.TaskDispatcher,
|
||||
logger *zap.Logger,
|
||||
pushWorker *service.PushWorker,
|
||||
) *App {
|
||||
return &App{
|
||||
Config: cfg,
|
||||
@@ -52,6 +54,7 @@ func NewApp(
|
||||
Publisher: publisher,
|
||||
TaskDispatcher: taskDispatcher,
|
||||
Logger: logger,
|
||||
PushWorker: pushWorker,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +76,11 @@ func (a *App) Start(ctx context.Context) error {
|
||||
a.HotRankWorker.Start(ctx)
|
||||
}
|
||||
|
||||
// 2.2 启动 PushWorker(Redis Stream 异步推送)
|
||||
if a.PushWorker != nil {
|
||||
a.PushWorker.Start(ctx)
|
||||
}
|
||||
|
||||
// 3. 创建 HTTP 服务器
|
||||
addr := fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port)
|
||||
a.Logger.Info("Starting HTTP server",
|
||||
@@ -148,6 +156,12 @@ func (a *App) Stop(ctx context.Context) error {
|
||||
a.HotRankWorker.Stop()
|
||||
}
|
||||
|
||||
// 5.1 停止 PushWorker
|
||||
if a.PushWorker != nil {
|
||||
a.Logger.Info("Stopping push worker (stream)")
|
||||
a.PushWorker.Stop()
|
||||
}
|
||||
|
||||
// 6. 关闭数据库连接
|
||||
a.Logger.Info("Closing database connection")
|
||||
sqlDB, err := a.DB.DB()
|
||||
|
||||
@@ -78,7 +78,10 @@ func InitializeApp() (*App, error) {
|
||||
return nil, err
|
||||
}
|
||||
uploadService := wire.ProvideUploadService(s3Client, userService)
|
||||
chatService := wire.ProvideChatService(messageRepository, userRepository, messagePublisher, cache, uploadService, pushService)
|
||||
seqBufferManager := wire.ProvideSeqBufferManager(config, client, cache)
|
||||
pushWorker := wire.ProvidePushWorker(config, client, messagePublisher, pushService, messageRepository, userRepository)
|
||||
conversationVersionLogRepository := repository.NewConversationVersionLogRepository(db)
|
||||
chatService := wire.ProvideChatService(messageRepository, userRepository, messagePublisher, cache, uploadService, pushService, seqBufferManager, pushWorker, client, conversationVersionLogRepository, config)
|
||||
messageService := wire.ProvideMessageService(messageRepository, cache, uploadService)
|
||||
groupRepository := repository.NewGroupRepository(db)
|
||||
groupJoinRequestRepository := repository.NewGroupJoinRequestRepository(db)
|
||||
@@ -158,7 +161,7 @@ func InitializeApp() (*App, error) {
|
||||
server := wire.ProvideGRPCServer(config, logger, runnerHub)
|
||||
runnerRegistry := wire.ProvideRunnerRegistry(config, client)
|
||||
taskDispatcher := wire.ProvideTaskDispatcher(runnerHub, runnerRegistry, config, client, logger)
|
||||
app := NewApp(config, db, router, pushService, hotRankWorker, server, messagePublisher, taskDispatcher, logger)
|
||||
app := NewApp(config, db, router, pushService, hotRankWorker, server, messagePublisher, taskDispatcher, logger, pushWorker)
|
||||
return app, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -312,3 +312,24 @@ websocket:
|
||||
msg_channel: "ws:msg" # Redis Pub/Sub 频道
|
||||
online_ttl: 60 # 在线状态 TTL(秒)
|
||||
heartbeat_interval: 20 # 心跳间隔(秒)
|
||||
|
||||
# Seq 预分配配置
|
||||
# 环境变量:
|
||||
# APP_SEQ_BUFFER_ENABLED, APP_SEQ_BUFFER_PRIVATE_BUFFER_SIZE
|
||||
# APP_SEQ_BUFFER_GROUP_BUFFER_SIZE, APP_SEQ_BUFFER_LOCK_TIMEOUT_MS
|
||||
# APP_SEQ_BUFFER_MAX_RETRIES
|
||||
seq_buffer:
|
||||
enabled: false # 启用后使用 Lua 预分配代替 INCR,关闭则回退旧逻辑
|
||||
private_buffer_size: 50 # 私聊每次向 Redis 预分配步长
|
||||
group_buffer_size: 200 # 群聊每次向 Redis 预分配步长(群聊消息更频繁)
|
||||
lock_timeout_ms: 5000 # 分布式锁超时(毫秒)
|
||||
max_retries: 3 # 冷启动获取锁失败最大重试次数
|
||||
|
||||
# 版本日志增量同步配置
|
||||
# 环境变量:
|
||||
# APP_VERSION_LOG_ENABLED, APP_VERSION_LOG_SYNC_LIMIT
|
||||
# APP_VERSION_LOG_MAX_SYNC_GAP
|
||||
version_log:
|
||||
enabled: false # 启用后客户端可使用 /conversations/sync 增量同步
|
||||
sync_limit: 100 # 单次同步最大返回变更条数
|
||||
max_sync_gap: 1000 # 版本差距超过此值建议全量同步
|
||||
|
||||
22
internal/cache/conversation_cache.go
vendored
22
internal/cache/conversation_cache.go
vendored
@@ -234,10 +234,11 @@ type ConversationCache struct {
|
||||
settings *ConversationCacheSettings // 配置
|
||||
repo ConversationRepository // 数据仓库接口(用于 cache-aside 回源)
|
||||
msgRepo MessageRepository // 消息数据仓库接口(用于消息缓存回源)
|
||||
seqBufferMgr *SeqBufferManager // seq 预分配管理器(nil 则使用旧 INCR 逻辑)
|
||||
}
|
||||
|
||||
// NewConversationCache 创建会话缓存管理器
|
||||
func NewConversationCache(cache Cache, repo ConversationRepository, msgRepo MessageRepository, settings *ConversationCacheSettings) *ConversationCache {
|
||||
func NewConversationCache(cache Cache, repo ConversationRepository, msgRepo MessageRepository, settings *ConversationCacheSettings, seqBufferMgr *SeqBufferManager) *ConversationCache {
|
||||
if settings == nil {
|
||||
settings = DefaultConversationCacheSettings()
|
||||
}
|
||||
@@ -246,6 +247,7 @@ func NewConversationCache(cache Cache, repo ConversationRepository, msgRepo Mess
|
||||
settings: settings,
|
||||
repo: repo,
|
||||
msgRepo: msgRepo,
|
||||
seqBufferMgr: seqBufferMgr,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,8 +696,16 @@ func (c *ConversationCache) ComputeAllUnreadCount(ctx context.Context, userID st
|
||||
}
|
||||
|
||||
// GetNextSeq 获取会话的下一个 seq 值(原子递增)
|
||||
// 使用 Redis INCR 实现,首次调用时从 DB 初始化并补齐差值
|
||||
// 启用 seq 预分配时使用本地缓冲区 + Redis Lua 分配;否则使用旧 INCR 逻辑
|
||||
func (c *ConversationCache) GetNextSeq(ctx context.Context, convID string) (int64, error) {
|
||||
// 优先使用 seq 预分配
|
||||
if c.seqBufferMgr != nil && c.seqBufferMgr.IsEnabled() {
|
||||
// 判断是否群聊(通过会话类型判断,这里先按私聊处理,由上层传入 isGroup)
|
||||
// 由于接口只传 convID,这里默认私聊逻辑,群聊的 isGroup 由调用方决定
|
||||
return c.seqBufferMgr.GetNextSeq(ctx, convID, false)
|
||||
}
|
||||
|
||||
// 降级到旧 INCR 逻辑
|
||||
seqKey := MessageSeqKey(convID)
|
||||
|
||||
newSeq, err := c.cache.Incr(ctx, seqKey)
|
||||
@@ -732,6 +742,14 @@ func (c *ConversationCache) GetNextSeq(ctx context.Context, convID string) (int6
|
||||
return newSeq, nil
|
||||
}
|
||||
|
||||
// GetNextSeqWithGroup 获取会话的下一个 seq 值(支持群聊标识,用于 seq 预分配步长区分)
|
||||
func (c *ConversationCache) GetNextSeqWithGroup(ctx context.Context, convID string, isGroup bool) (int64, error) {
|
||||
if c.seqBufferMgr != nil && c.seqBufferMgr.IsEnabled() {
|
||||
return c.seqBufferMgr.GetNextSeq(ctx, convID, isGroup)
|
||||
}
|
||||
return c.GetNextSeq(ctx, convID)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 消息缓存方法
|
||||
// ============================================================
|
||||
|
||||
6
internal/cache/keys.go
vendored
6
internal/cache/keys.go
vendored
@@ -44,6 +44,7 @@ const (
|
||||
keyPrefixMsgSeq = "msg_seq" // Seq 计数器
|
||||
keyPrefixMsgPage = "msg_page" // 分页缓存
|
||||
keyPrefixMsgIdempotent = "msg_idem" // 消息幂等键
|
||||
keyPrefixSeqBuffer = "seq_buf" // Seq 预分配缓冲区 Hash
|
||||
)
|
||||
|
||||
// PostListKey 生成帖子列表缓存键
|
||||
@@ -197,6 +198,11 @@ func MessageIdempotentKey(senderID, clientMsgID string) string {
|
||||
return fmt.Sprintf("%s:%s:%s", keyPrefixMsgIdempotent, senderID, clientMsgID)
|
||||
}
|
||||
|
||||
// SeqBufferKey seq 预分配缓冲区 Hash key
|
||||
func SeqBufferKey(convID string) string {
|
||||
return fmt.Sprintf("%s:%s", keyPrefixSeqBuffer, convID)
|
||||
}
|
||||
|
||||
|
||||
// UserReadSeqKey 用户已读位置缓存键(OpenIM 风格 SEQ_USER_READ:{convID}:{userID})
|
||||
func UserReadSeqKey(convID, userID string) string {
|
||||
|
||||
303
internal/cache/seq_buffer.go
vendored
Normal file
303
internal/cache/seq_buffer.go
vendored
Normal file
@@ -0,0 +1,303 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
redisPkg "with_you/internal/pkg/redis"
|
||||
)
|
||||
|
||||
// SeqBufferConfig seq 预分配配置
|
||||
type SeqBufferConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
PrivateBufferSize int `mapstructure:"private_buffer_size"` // 私聊预分配步长
|
||||
GroupBufferSize int `mapstructure:"group_buffer_size"` // 群聊预分配步长
|
||||
LockTimeoutMs int `mapstructure:"lock_timeout_ms"` // 分布式锁超时(ms)
|
||||
MaxRetries int `mapstructure:"max_retries"` // 冷启动重试次数
|
||||
}
|
||||
|
||||
// localSeqBuffer 本地 seq 缓冲区
|
||||
type localSeqBuffer struct {
|
||||
mu sync.Mutex
|
||||
curr int64 // 当前已分配到的 seq
|
||||
last int64 // 本地缓冲区的上界
|
||||
}
|
||||
|
||||
// SeqBufferManager seq 预分配管理器
|
||||
type SeqBufferManager struct {
|
||||
rdb *redis.Client // 原始 Redis 客户端(用于执行 Lua 脚本)
|
||||
config SeqBufferConfig
|
||||
repo ConversationRepository
|
||||
msgRepo MessageRepository
|
||||
buffers sync.Map // convID -> *localSeqBuffer
|
||||
}
|
||||
|
||||
// mallocLua 预分配 Lua 跟踪脚本
|
||||
// KEYS[1] = seq_buf:{convID} (Hash: CURR/LAST/LOCK/TIME)
|
||||
// KEYS[2] = msg_seq:{convID} (String, 兼容旧 INCR key)
|
||||
// ARGV[1] = bufferSize
|
||||
// ARGV[2] = lockTimeoutMs (毫秒)
|
||||
// ARGV[3] = current timestamp (毫秒)
|
||||
//
|
||||
// 返回:
|
||||
// status=1, startSeq, endSeq — 成功分配
|
||||
// status=2 — 冷启动(key 不存在)
|
||||
// status=0 — 获取锁失败
|
||||
var mallocLua = redis.NewScript(`
|
||||
local bufKey = KEYS[1]
|
||||
local seqKey = KEYS[2]
|
||||
local bufSize = tonumber(ARGV[1])
|
||||
local lockMs = tonumber(ARGV[2])
|
||||
local nowMs = tonumber(ARGV[3])
|
||||
|
||||
-- 尝试获取分布式锁
|
||||
local lockVal = redis.call('HGET', bufKey, 'LOCK')
|
||||
if lockVal then
|
||||
local lockTs = tonumber(lockVal)
|
||||
if lockTs > 0 and (nowMs - lockTs) < lockMs then
|
||||
return {0, 0, 0}
|
||||
end
|
||||
end
|
||||
redis.call('HSET', bufKey, 'LOCK', nowMs)
|
||||
redis.call('HSET', bufKey, 'TIME', nowMs)
|
||||
|
||||
local curr = tonumber(redis.call('HGET', bufKey, 'CURR'))
|
||||
local last = tonumber(redis.call('HGET', bufKey, 'LAST'))
|
||||
|
||||
if curr == nil or last == nil then
|
||||
-- 冷启动:key 不存在
|
||||
redis.call('HDEL', bufKey, 'LOCK')
|
||||
return {2, 0, 0}
|
||||
end
|
||||
|
||||
if curr < last then
|
||||
-- 本地缓冲区仍有余量,直接分配一个 seq
|
||||
local newCurr = redis.call('HINCRBY', bufKey, 'CURR', 1)
|
||||
redis.call('HDEL', bufKey, 'LOCK')
|
||||
return {1, newCurr, last}
|
||||
end
|
||||
|
||||
-- 缓冲区耗尽,扩展 LAST
|
||||
local newLast = last + bufSize
|
||||
redis.call('HSET', bufKey, 'LAST', newLast)
|
||||
-- 同步更新 msg_seq(兼容旧 INCR key,确保 READ 的值不回退)
|
||||
local oldSeq = tonumber(redis.call('GET', seqKey))
|
||||
if oldSeq and oldSeq > newLast then
|
||||
newLast = oldSeq + bufSize
|
||||
redis.call('HSET', bufKey, 'LAST', newLast)
|
||||
end
|
||||
redis.call('SET', seqKey, newLast)
|
||||
redis.call('HDEL', bufKey, 'LOCK')
|
||||
return {1, curr + 1, newLast}
|
||||
`)
|
||||
|
||||
// initLua 冷启动初始化 Lua 跟踪脚本
|
||||
// KEYS[1] = seq_buf:{convID}
|
||||
// KEYS[2] = msg_seq:{convID}
|
||||
// ARGV[1] = startSeq (从 DB 获取的 conv.LastSeq)
|
||||
// ARGV[2] = bufferSize
|
||||
// ARGV[3] = lockTimeoutMs
|
||||
// ARGV[4] = current timestamp (毫秒)
|
||||
var initLua = redis.NewScript(`
|
||||
local bufKey = KEYS[1]
|
||||
local seqKey = KEYS[2]
|
||||
local startSeq = tonumber(ARGV[1])
|
||||
local bufSize = tonumber(ARGV[2])
|
||||
local lockMs = tonumber(ARGV[3])
|
||||
local nowMs = tonumber(ARGV[4])
|
||||
|
||||
-- 创建 seq_buf Hash
|
||||
redis.call('HSET', bufKey, 'CURR', startSeq)
|
||||
redis.call('HSET', bufKey, 'LAST', startSeq + bufSize)
|
||||
redis.call('HSET', bufKey, 'LOCK', 0)
|
||||
redis.call('HSET', bufKey, 'TIME', nowMs)
|
||||
|
||||
-- 同步更新 msg_seq(兼容旧 INCR key)
|
||||
local oldSeq = tonumber(redis.call('GET', seqKey))
|
||||
if oldSeq and oldSeq > (startSeq + bufSize) then
|
||||
redis.call('HSET', bufKey, 'LAST', oldSeq + bufSize)
|
||||
redis.call('SET', seqKey, oldSeq + bufSize)
|
||||
else
|
||||
redis.call('SET', seqKey, startSeq + bufSize)
|
||||
end
|
||||
|
||||
return {1, startSeq + 1, startSeq + bufSize}
|
||||
`)
|
||||
|
||||
// NewSeqBufferManager 创建 seq 预分配管理器
|
||||
func NewSeqBufferManager(rdb *redis.Client, cfg SeqBufferConfig, repo ConversationRepository, msgRepo MessageRepository) *SeqBufferManager {
|
||||
if cfg.PrivateBufferSize <= 0 {
|
||||
cfg.PrivateBufferSize = 50
|
||||
}
|
||||
if cfg.GroupBufferSize <= 0 {
|
||||
cfg.GroupBufferSize = 200
|
||||
}
|
||||
if cfg.LockTimeoutMs <= 0 {
|
||||
cfg.LockTimeoutMs = 5000
|
||||
}
|
||||
if cfg.MaxRetries <= 0 {
|
||||
cfg.MaxRetries = 3
|
||||
}
|
||||
return &SeqBufferManager{
|
||||
rdb: rdb,
|
||||
config: cfg,
|
||||
repo: repo,
|
||||
msgRepo: msgRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// GetNextSeq 获取下一个 seq(从本地缓冲区分配,耗尽时向 Redis 预分配新区间)
|
||||
func (m *SeqBufferManager) GetNextSeq(ctx context.Context, convID string, isGroup bool) (int64, error) {
|
||||
bufSize := m.config.PrivateBufferSize
|
||||
if isGroup {
|
||||
bufSize = m.config.GroupBufferSize
|
||||
}
|
||||
|
||||
// 1. 检查本地缓冲区
|
||||
bufVal, ok := m.buffers.Load(convID)
|
||||
if ok {
|
||||
buf := bufVal.(*localSeqBuffer)
|
||||
buf.mu.Lock()
|
||||
if buf.curr < buf.last {
|
||||
seq := buf.curr + 1
|
||||
buf.curr = seq
|
||||
buf.mu.Unlock()
|
||||
return seq, nil
|
||||
}
|
||||
buf.mu.Unlock()
|
||||
}
|
||||
|
||||
// 2. 本地缓冲区耗尽或不存在,向 Redis 预分配
|
||||
startSeq, endSeq, err := m.allocateFromRedis(ctx, convID, bufSize)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("allocate from redis: %w", err)
|
||||
}
|
||||
|
||||
// 3. 更新本地缓冲区
|
||||
newBuf := &localSeqBuffer{curr: startSeq, last: endSeq}
|
||||
m.buffers.Store(convID, newBuf)
|
||||
|
||||
// startSeq 已经被 Redis 分配,返回它
|
||||
return startSeq, nil
|
||||
}
|
||||
|
||||
// allocateFromRedis 向 Redis 预分配 seq 区间
|
||||
func (m *SeqBufferManager) allocateFromRedis(ctx context.Context, convID string, bufferSize int) (startSeq, endSeq int64, err error) {
|
||||
bufKey := SeqBufferKey(convID)
|
||||
seqKey := MessageSeqKey(convID)
|
||||
nowMs := time.Now().UnixMilli()
|
||||
|
||||
for i := 0; i < m.config.MaxRetries; i++ {
|
||||
result, err := mallocLua.Run(ctx, m.rdb, []string{bufKey, seqKey}, bufferSize, m.config.LockTimeoutMs, nowMs).Int64Slice()
|
||||
if err != nil {
|
||||
// Redis 不可用,降级到 DB
|
||||
zap.L().Warn("seq malloc lua failed, falling back to DB", zap.String("convID", convID), zap.Error(err))
|
||||
if m.msgRepo != nil {
|
||||
if dbSeq, dbErr := m.msgRepo.GetNextSeq(convID); dbErr == nil {
|
||||
return dbSeq, dbSeq, nil
|
||||
}
|
||||
}
|
||||
return 0, 0, fmt.Errorf("lua script error: %w", err)
|
||||
}
|
||||
|
||||
status := result[0]
|
||||
switch status {
|
||||
case 1:
|
||||
// 成功分配
|
||||
return result[1], result[2], nil
|
||||
case 2:
|
||||
// 冷启动,需要从 DB 初始化
|
||||
startSeq, endSeq, initErr := m.initFromDB(ctx, convID, bufferSize)
|
||||
if initErr != nil {
|
||||
return 0, 0, fmt.Errorf("init from db: %w", initErr)
|
||||
}
|
||||
return startSeq, endSeq, nil
|
||||
case 0:
|
||||
// 获取锁失败,短暂等待后重试
|
||||
time.Sleep(time.Duration(50) * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 所有重试失败,降级到 DB
|
||||
zap.L().Warn("seq malloc retries exhausted, falling back to DB", zap.String("convID", convID))
|
||||
if m.msgRepo != nil {
|
||||
if dbSeq, dbErr := m.msgRepo.GetNextSeq(convID); dbErr == nil {
|
||||
return dbSeq, dbSeq, nil
|
||||
}
|
||||
}
|
||||
return 0, 0, fmt.Errorf("malloc retries exhausted")
|
||||
}
|
||||
|
||||
// initFromDB 冷启动时从 DB 加载 LastSeq 并初始化 Redis seq_buf Hash
|
||||
func (m *SeqBufferManager) initFromDB(ctx context.Context, convID string, bufferSize int) (startSeq, endSeq int64, err error) {
|
||||
if m.repo == nil {
|
||||
return 0, 0, fmt.Errorf("conversation repository not configured")
|
||||
}
|
||||
|
||||
conv, err := m.repo.GetConversationByID(convID)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("load conversation: %w", err)
|
||||
}
|
||||
|
||||
dbLastSeq := conv.LastSeq
|
||||
if dbLastSeq <= 0 {
|
||||
dbLastSeq = 0
|
||||
}
|
||||
|
||||
bufKey := SeqBufferKey(convID)
|
||||
seqKey := MessageSeqKey(convID)
|
||||
nowMs := time.Now().UnixMilli()
|
||||
|
||||
result, err := initLua.Run(ctx, m.rdb, []string{bufKey, seqKey}, dbLastSeq, bufferSize, m.config.LockTimeoutMs, nowMs).Int64Slice()
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("init lua script error: %w", err)
|
||||
}
|
||||
|
||||
zap.L().Info("seq buffer cold-start initialized",
|
||||
zap.String("convID", convID),
|
||||
zap.Int64("dbLastSeq", dbLastSeq),
|
||||
zap.Int64("startSeq", result[1]),
|
||||
zap.Int64("endSeq", result[2]),
|
||||
)
|
||||
|
||||
return result[1], result[2], nil
|
||||
}
|
||||
|
||||
// InvalidateBuffer 清除本地缓冲区(会话 seq 被外部重置时调用)
|
||||
func (m *SeqBufferManager) InvalidateBuffer(convID string) {
|
||||
m.buffers.Delete(convID)
|
||||
}
|
||||
|
||||
// IsEnabled 是否启用预分配
|
||||
func (m *SeqBufferManager) IsEnabled() bool {
|
||||
return m.config.Enabled && m.rdb != nil
|
||||
}
|
||||
|
||||
// NewSeqBufferManagerFromCache 从 ConversationCache 的 Cache 接口创建 SeqBufferManager
|
||||
// 当 wire 中不方便注入 redis.Client 时,可通过 Cache 接口获取底层 Redis 连接
|
||||
func NewSeqBufferManagerFromCache(cacheBackend Cache, cfg SeqBufferConfig, repo ConversationRepository, msgRepo MessageRepository) *SeqBufferManager {
|
||||
redisCache, ok := cacheBackend.(*RedisCache)
|
||||
if !ok {
|
||||
zap.L().Warn("SeqBuffer: cache is not RedisCache, seq pre-allocation disabled")
|
||||
return &SeqBufferManager{config: cfg, repo: repo, msgRepo: msgRepo}
|
||||
}
|
||||
|
||||
rdb := redisCache.GetRedisClient().GetClient()
|
||||
return NewSeqBufferManager(rdb, cfg, repo, msgRepo)
|
||||
}
|
||||
|
||||
// NewSeqBufferManagerFromRedisPkg 从 pkg/redis.Client 创建 SeqBufferManager
|
||||
func NewSeqBufferManagerFromRedisPkg(redisPkgClient *redisPkg.Client, cfg SeqBufferConfig, repo ConversationRepository, msgRepo MessageRepository) *SeqBufferManager {
|
||||
if redisPkgClient == nil {
|
||||
zap.L().Warn("SeqBuffer: redis client is nil, seq pre-allocation disabled")
|
||||
return &SeqBufferManager{config: cfg, repo: repo, msgRepo: msgRepo}
|
||||
}
|
||||
rdb := redisPkgClient.GetClient()
|
||||
return NewSeqBufferManager(rdb, cfg, repo, msgRepo)
|
||||
}
|
||||
@@ -34,6 +34,9 @@ type Config struct {
|
||||
SetupSecret string `mapstructure:"setup_secret"`
|
||||
WebSocket WSConfig `mapstructure:"websocket"`
|
||||
Runner RunnerConfig `mapstructure:"runner"`
|
||||
SeqBuffer SeqBufferConfig `mapstructure:"seq_buffer"`
|
||||
VersionLog VersionLogConfig `mapstructure:"version_log"`
|
||||
PushWorker PushWorkerConfig `mapstructure:"push_worker"`
|
||||
}
|
||||
|
||||
// Load 加载配置文件
|
||||
@@ -191,6 +194,25 @@ func Load(configPath string) (*Config, error) {
|
||||
viper.SetDefault("runner.cluster.cap_prefix", "runner:cap:")
|
||||
viper.SetDefault("runner.cluster.registry_ttl", 90)
|
||||
viper.SetDefault("runner.cluster.heartbeat_interval", 30)
|
||||
// SeqBuffer 默认值
|
||||
viper.SetDefault("seq_buffer.enabled", false)
|
||||
viper.SetDefault("seq_buffer.private_buffer_size", 50)
|
||||
viper.SetDefault("seq_buffer.group_buffer_size", 200)
|
||||
viper.SetDefault("seq_buffer.lock_timeout_ms", 5000)
|
||||
viper.SetDefault("seq_buffer.max_retries", 3)
|
||||
// VersionLog 默认值
|
||||
viper.SetDefault("version_log.enabled", false)
|
||||
viper.SetDefault("version_log.sync_limit", 100)
|
||||
viper.SetDefault("version_log.max_sync_gap", 1000)
|
||||
// PushWorker 默认值
|
||||
viper.SetDefault("push_worker.enabled", false)
|
||||
viper.SetDefault("push_worker.stream", "msg_push")
|
||||
viper.SetDefault("push_worker.group", "push_worker")
|
||||
viper.SetDefault("push_worker.batch_size", 50)
|
||||
viper.SetDefault("push_worker.poll_timeout_ms", 2000)
|
||||
viper.SetDefault("push_worker.max_retries", 3)
|
||||
viper.SetDefault("push_worker.max_stream_len", 100000)
|
||||
viper.SetDefault("push_worker.idle_timeout_ms", 30000)
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read config: %w", err)
|
||||
|
||||
13
internal/config/push_worker.go
Normal file
13
internal/config/push_worker.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package config
|
||||
|
||||
// PushWorkerConfig 推送 Worker 配置(Redis Stream 异步推送)
|
||||
type PushWorkerConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Stream string `mapstructure:"stream"` // Redis Stream 名称,默认 "msg_push"
|
||||
Group string `mapstructure:"group"` // Consumer Group 名称,默认 "push_worker"
|
||||
BatchSize int `mapstructure:"batch_size"` // XREADGROUP 每次读取条数,默认 50
|
||||
PollTimeoutMs int `mapstructure:"poll_timeout_ms"` // XREADGROUP BLOCK 超时 ms,默认 2000
|
||||
MaxRetries int `mapstructure:"max_retries"` // 消息最大重试次数,默认 3
|
||||
MaxStreamLen int64 `mapstructure:"max_stream_len"` // Stream MAXLEN,默认 100000
|
||||
IdleTimeoutMs int64 `mapstructure:"idle_timeout_ms"` // XPENDING 空闲超时 ms,默认 30000
|
||||
}
|
||||
10
internal/config/seq_buffer.go
Normal file
10
internal/config/seq_buffer.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package config
|
||||
|
||||
// SeqBufferConfig seq 预分配配置
|
||||
type SeqBufferConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
PrivateBufferSize int `mapstructure:"private_buffer_size"` // 私聊预分配步长(默认 50)
|
||||
GroupBufferSize int `mapstructure:"group_buffer_size"` // 群聊预分配步长(默认 200)
|
||||
LockTimeoutMs int `mapstructure:"lock_timeout_ms"` // 分布式锁超时毫秒(默认 5000)
|
||||
MaxRetries int `mapstructure:"max_retries"` // 冷启动重试次数(默认 3)
|
||||
}
|
||||
8
internal/config/version_log.go
Normal file
8
internal/config/version_log.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package config
|
||||
|
||||
// VersionLogConfig 版本日志增量同步配置
|
||||
type VersionLogConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
SyncLimit int `mapstructure:"sync_limit"` // 单次同步最大返回条数
|
||||
MaxSyncGap int64 `mapstructure:"max_sync_gap"` // 超过此版本差距建议全量同步
|
||||
}
|
||||
23
internal/dto/sync_dto.go
Normal file
23
internal/dto/sync_dto.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package dto
|
||||
|
||||
// SyncVersionRequest 增量同步请求
|
||||
type SyncVersionRequest struct {
|
||||
Version int64 `form:"version" binding:"min=0"` // 客户端上次同步版本号,0 表示全量同步
|
||||
}
|
||||
|
||||
// SyncVersionResponse 增量同步响应
|
||||
type SyncVersionResponse struct {
|
||||
CurrentVersion int64 `json:"current_version"` // 服务端当前最大版本号
|
||||
Changes []SyncChangeItem `json:"changes"` // 变更列表
|
||||
FullSync bool `json:"full_sync"` // 是否需要全量同步(版本差距过大时)
|
||||
HasMore bool `json:"has_more"` // 是否还有更多变更
|
||||
}
|
||||
|
||||
// SyncChangeItem 单条变更项
|
||||
type SyncChangeItem struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
ChangeType string `json:"change_type"` // new_msg, read, pin, unpin, mute, unmute, hide, group_update
|
||||
MaxSeq int64 `json:"max_seq,omitempty"`
|
||||
LastMessageAt int64 `json:"last_message_at,omitempty"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
@@ -585,6 +585,37 @@ func (h *MessageHandler) HandleGetSyncData(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// HandleGetSyncByVersion 增量同步:按版本号获取会话变更
|
||||
// GET /api/v1/conversations/sync?version=0&limit=100
|
||||
func (h *MessageHandler) HandleGetSyncByVersion(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
versionStr := c.DefaultQuery("version", "0")
|
||||
sinceVersion, err := strconv.ParseInt(versionStr, 10, 64)
|
||||
if err != nil || sinceVersion < 0 {
|
||||
response.BadRequest(c, "invalid version parameter")
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := c.DefaultQuery("limit", "100")
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit < 1 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
result, err := h.chatService.GetSyncByVersion(c.Request.Context(), userID, sinceVersion, limit)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
// GetConversationUnreadCount 获取单个会话的未读数
|
||||
// GET /api/conversations/:id/unread/count
|
||||
func (h *MessageHandler) GetConversationUnreadCount(c *gin.Context) {
|
||||
|
||||
@@ -151,12 +151,14 @@ func (h *WSHandler) HandleWebSocket(c *gin.Context) {
|
||||
|
||||
// 3. 创建客户端
|
||||
clientID := atomic.AddUint64(&h.clientSeq, 1)
|
||||
compressEnabled := c.Query("compress") == "1"
|
||||
client := &ws.Client{
|
||||
ID: clientID,
|
||||
UserID: userID,
|
||||
Send: make(chan []byte, defaultUserBufferSize),
|
||||
Quit: make(chan struct{}),
|
||||
PendingAcks: make(map[string]time.Time),
|
||||
CompressEnabled: compressEnabled,
|
||||
}
|
||||
|
||||
// 4. 注册客户端
|
||||
@@ -243,9 +245,21 @@ func (h *WSHandler) readPump(conn *websocket.Conn, client *ws.Client) {
|
||||
break
|
||||
}
|
||||
|
||||
// 解析消息
|
||||
// 解析消息(支持 gzip 解压)
|
||||
var rawData []byte
|
||||
if client.CompressEnabled && ws.IsCompressed(message) {
|
||||
decompressed, dErr := ws.DefaultCompressor.Decompress(message)
|
||||
if dErr != nil {
|
||||
h.publisher.SendError(client, "decompress_error", "failed to decompress gzip message")
|
||||
continue
|
||||
}
|
||||
rawData = decompressed
|
||||
} else {
|
||||
rawData = message
|
||||
}
|
||||
|
||||
var msg ws.Message
|
||||
if err := json.Unmarshal(message, &msg); err != nil {
|
||||
if err := json.Unmarshal(rawData, &msg); err != nil {
|
||||
h.publisher.SendError(client, "parse_error", "invalid message format")
|
||||
continue
|
||||
}
|
||||
@@ -280,6 +294,35 @@ func (h *WSHandler) writePump(conn *websocket.Conn, client *ws.Client) {
|
||||
return
|
||||
}
|
||||
|
||||
if client.CompressEnabled {
|
||||
buf := make([]byte, 0, len(message)+64)
|
||||
buf = append(buf, message...)
|
||||
n := len(client.Send)
|
||||
for i := 0; i < n; i++ {
|
||||
buf = append(buf, '\n')
|
||||
buf = append(buf, <-client.Send...)
|
||||
}
|
||||
compressed, cErr := ws.DefaultCompressor.Compress(buf)
|
||||
if cErr != nil {
|
||||
zap.L().Warn("WebSocket compress error, sending raw",
|
||||
zap.String("user_id", client.UserID),
|
||||
zap.Error(cErr),
|
||||
)
|
||||
w, err := conn.NextWriter(websocket.TextMessage)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
w.Write(buf)
|
||||
w.Close()
|
||||
} else {
|
||||
w, err := conn.NextWriter(websocket.BinaryMessage)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
w.Write(compressed)
|
||||
w.Close()
|
||||
}
|
||||
} else {
|
||||
w, err := conn.NextWriter(websocket.TextMessage)
|
||||
if err != nil {
|
||||
zap.L().Warn("WebSocket write error (NextWriter)",
|
||||
@@ -291,7 +334,6 @@ func (h *WSHandler) writePump(conn *websocket.Conn, client *ws.Client) {
|
||||
}
|
||||
w.Write(message)
|
||||
|
||||
// 批量发送缓冲区中的消息
|
||||
n := len(client.Send)
|
||||
for i := 0; i < n; i++ {
|
||||
w.Write([]byte{'\n'})
|
||||
@@ -306,6 +348,7 @@ func (h *WSHandler) writePump(conn *websocket.Conn, client *ws.Client) {
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
case <-ticker.C:
|
||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
|
||||
13
internal/model/conversation_version_log.go
Normal file
13
internal/model/conversation_version_log.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ConversationVersionLog 会话版本日志(用于增量同步)
|
||||
type ConversationVersionLog struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
UserID string `gorm:"not null;size:36;index:idx_vlog_user_version,priority:1"`
|
||||
ConversationID string `gorm:"not null;size:20;index:idx_vlog_conv"`
|
||||
Version int64 `gorm:"not null;index:idx_vlog_user_version,priority:2"`
|
||||
ChangeType string `gorm:"not null;type:varchar(20)"` // new_msg, read, pin, unpin, mute, unmute, hide, group_update
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
}
|
||||
71
internal/pkg/ws/compress.go
Normal file
71
internal/pkg/ws/compress.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const gzipMagicByte = 0x1F
|
||||
|
||||
// Compressor 管理 gzip Writer/Reader 的 sync.Pool 复用
|
||||
type Compressor struct {
|
||||
writerPool sync.Pool
|
||||
readerPool sync.Pool
|
||||
level int
|
||||
}
|
||||
|
||||
// NewCompressor 创建 Compressor(level: gzip.DefaultCompression/BestSpeed/BestCompression 等)
|
||||
func NewCompressor(level int) *Compressor {
|
||||
return &Compressor{
|
||||
level: level,
|
||||
writerPool: sync.Pool{
|
||||
New: func() any {
|
||||
w, _ := gzip.NewWriterLevel(io.Discard, level)
|
||||
return w
|
||||
},
|
||||
},
|
||||
readerPool: sync.Pool{
|
||||
New: func() any { return new(gzip.Reader) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Compress 压缩数据,返回 gzip 字节
|
||||
func (c *Compressor) Compress(data []byte) ([]byte, error) {
|
||||
buf := &bytes.Buffer{}
|
||||
w := c.writerPool.Get().(*gzip.Writer)
|
||||
w.Reset(buf)
|
||||
if _, err := w.Write(data); err != nil {
|
||||
c.writerPool.Put(w)
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
c.writerPool.Put(w)
|
||||
return nil, err
|
||||
}
|
||||
c.writerPool.Put(w)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// Decompress 解压 gzip 数据
|
||||
func (c *Compressor) Decompress(data []byte) ([]byte, error) {
|
||||
r := c.readerPool.Get().(*gzip.Reader)
|
||||
if err := r.Reset(bytes.NewReader(data)); err != nil {
|
||||
c.readerPool.Put(r)
|
||||
return nil, err
|
||||
}
|
||||
out, err := io.ReadAll(r)
|
||||
r.Close()
|
||||
c.readerPool.Put(r)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// IsCompressed 检查数据是否以 gzip magic byte 开头
|
||||
func IsCompressed(data []byte) bool {
|
||||
return len(data) > 0 && data[0] == gzipMagicByte
|
||||
}
|
||||
|
||||
// DefaultCompressor 全局默认压缩器(gzip.DefaultCompression = level 6)
|
||||
var DefaultCompressor = NewCompressor(gzip.DefaultCompression)
|
||||
94
internal/pkg/ws/compress_test.go
Normal file
94
internal/pkg/ws/compress_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompressDecompressRoundTrip(t *testing.T) {
|
||||
c := NewCompressor(6)
|
||||
|
||||
original := []byte(`{"type":"chat_message","payload":{"conversation_id":"conv1","message":"hello world"}}`)
|
||||
|
||||
compressed, err := c.Compress(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Compress failed: %v", err)
|
||||
}
|
||||
|
||||
if len(compressed) >= len(original) {
|
||||
t.Logf("warning: compressed size (%d) >= original size (%d), but this can happen for small payloads", len(compressed), len(original))
|
||||
}
|
||||
|
||||
if !IsCompressed(compressed) {
|
||||
t.Fatal("IsCompressed should return true for gzip data")
|
||||
}
|
||||
|
||||
decompressed, err := c.Decompress(compressed)
|
||||
if err != nil {
|
||||
t.Fatalf("Decompress failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(decompressed, original) {
|
||||
t.Fatalf("round-trip mismatch: got %q, want %q", decompressed, original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCompressed(t *testing.T) {
|
||||
if IsCompressed([]byte{}) {
|
||||
t.Fatal("empty data should not be considered compressed")
|
||||
}
|
||||
if IsCompressed([]byte{0x00}) {
|
||||
t.Fatal("0x00 should not be considered gzip magic byte")
|
||||
}
|
||||
if !IsCompressed([]byte{0x1F, 0x8B}) {
|
||||
t.Fatal("gzip magic bytes should be detected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecompressNonGzipData(t *testing.T) {
|
||||
c := NewCompressor(6)
|
||||
_, err := c.Decompress([]byte("not gzip data"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error when decompressing non-gzip data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCompressor(t *testing.T) {
|
||||
data := []byte("test data for default compressor")
|
||||
compressed, err := DefaultCompressor.Compress(data)
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultCompressor.Compress failed: %v", err)
|
||||
}
|
||||
decompressed, err := DefaultCompressor.Decompress(compressed)
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultCompressor.Decompress failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decompressed, data) {
|
||||
t.Fatalf("DefaultCompressor round-trip mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCompress(b *testing.B) {
|
||||
c := NewCompressor(6)
|
||||
data := bytes.Repeat([]byte(`{"type":"chat_message","payload":{"message":"hello"}}`), 10)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := c.Compress(data)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDecompress(b *testing.B) {
|
||||
c := NewCompressor(6)
|
||||
data := bytes.Repeat([]byte(`{"type":"chat_message","payload":{"message":"hello"}}`), 10)
|
||||
compressed, _ := c.Compress(data)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := c.Decompress(compressed)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ type Client struct {
|
||||
Send chan []byte
|
||||
Quit chan struct{}
|
||||
PendingAcks map[string]time.Time
|
||||
CompressEnabled bool // 客户端是否启用 gzip 压缩
|
||||
}
|
||||
|
||||
// ErrConnectionLimit 连接数限制错误
|
||||
|
||||
58
internal/repository/conversation_version_log_repo.go
Normal file
58
internal/repository/conversation_version_log_repo.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"with_you/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ConversationVersionLogRepository 会话版本日志仓储接口
|
||||
type ConversationVersionLogRepository interface {
|
||||
Create(ctx context.Context, log *model.ConversationVersionLog) error
|
||||
BatchCreate(ctx context.Context, logs []*model.ConversationVersionLog) error
|
||||
GetByVersion(ctx context.Context, userID string, sinceVersion int64, limit int) ([]*model.ConversationVersionLog, error)
|
||||
GetMaxVersion(ctx context.Context, userID string) (int64, error)
|
||||
}
|
||||
|
||||
// conversationVersionLogRepository GORM 实现
|
||||
type conversationVersionLogRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewConversationVersionLogRepository 创建会话版本日志仓储
|
||||
func NewConversationVersionLogRepository(db *gorm.DB) ConversationVersionLogRepository {
|
||||
return &conversationVersionLogRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *conversationVersionLogRepository) Create(ctx context.Context, log *model.ConversationVersionLog) error {
|
||||
return r.db.WithContext(ctx).Create(log).Error
|
||||
}
|
||||
|
||||
func (r *conversationVersionLogRepository) BatchCreate(ctx context.Context, logs []*model.ConversationVersionLog) error {
|
||||
if len(logs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).CreateInBatches(logs, 100).Error
|
||||
}
|
||||
|
||||
func (r *conversationVersionLogRepository) GetByVersion(ctx context.Context, userID string, sinceVersion int64, limit int) ([]*model.ConversationVersionLog, error) {
|
||||
var logs []*model.ConversationVersionLog
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND version > ?", userID, sinceVersion).
|
||||
Order("version ASC").
|
||||
Limit(limit).
|
||||
Find(&logs).Error
|
||||
return logs, err
|
||||
}
|
||||
|
||||
func (r *conversationVersionLogRepository) GetMaxVersion(ctx context.Context, userID string) (int64, error) {
|
||||
var maxVersion int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&model.ConversationVersionLog{}).
|
||||
Where("user_id = ?", userID).
|
||||
Select("COALESCE(MAX(version), 0)").
|
||||
Scan(&maxVersion).Error
|
||||
return maxVersion, err
|
||||
}
|
||||
@@ -431,6 +431,7 @@ func (r *Router) setupRoutes() {
|
||||
conversations.PUT("/:id/notification_muted", r.messageHandler.HandleSetConversationNotificationMuted) // 免打扰设置
|
||||
conversations.GET("/unread/count", r.messageHandler.GetUnreadCount) // 未读数
|
||||
conversations.GET("/sync-data", r.messageHandler.HandleGetSyncData) // 同步元数据
|
||||
conversations.GET("/sync", r.messageHandler.HandleGetSyncByVersion) // 增量同步(版本日志)
|
||||
conversations.POST("/:id/typing", r.messageHandler.HandleTyping) // 输入状态
|
||||
conversations.DELETE("/:id/self", r.messageHandler.HandleDeleteConversationForSelf) // 删除会话(仅自己)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"with_you/internal/pkg/ws"
|
||||
"with_you/internal/repository"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -48,6 +49,9 @@ type ChatService interface {
|
||||
// 消息同步
|
||||
GetSyncData(ctx context.Context, userID string) ([]dto.SyncDataItem, error)
|
||||
|
||||
// 增量同步(版本日志)
|
||||
GetSyncByVersion(ctx context.Context, userID string, sinceVersion int64, limit int) (*dto.SyncVersionResponse, error)
|
||||
|
||||
// 消息扩展功能
|
||||
RecallMessage(ctx context.Context, messageID string, userID string) error
|
||||
DeleteMessage(ctx context.Context, messageID string, userID string) error
|
||||
@@ -80,6 +84,11 @@ type chatServiceImpl struct {
|
||||
|
||||
conversationCache *cache.ConversationCache
|
||||
uploadService *UploadService
|
||||
versionLogRepo repository.ConversationVersionLogRepository
|
||||
versionLogEnabled bool
|
||||
versionLogMaxGap int64
|
||||
pushWorker *PushWorker
|
||||
redisClient *redis.Client
|
||||
}
|
||||
|
||||
// NewChatService 创建聊天服务
|
||||
@@ -91,6 +100,10 @@ func NewChatService(
|
||||
cacheBackend cache.Cache,
|
||||
uploadService *UploadService,
|
||||
pushSvc PushService,
|
||||
seqBufferMgr *cache.SeqBufferManager,
|
||||
pushWorker *PushWorker,
|
||||
redisClient *redis.Client,
|
||||
versionLogRepo ...repository.ConversationVersionLogRepository,
|
||||
) ChatService {
|
||||
// 创建适配器
|
||||
convRepoAdapter := cache.NewConversationRepositoryAdapter(repo)
|
||||
@@ -102,9 +115,10 @@ func NewChatService(
|
||||
convRepoAdapter,
|
||||
msgRepoAdapter,
|
||||
cache.DefaultConversationCacheSettings(),
|
||||
seqBufferMgr,
|
||||
)
|
||||
|
||||
return &chatServiceImpl{
|
||||
impl := &chatServiceImpl{
|
||||
repo: repo,
|
||||
userRepo: userRepo,
|
||||
sensitive: sensitive,
|
||||
@@ -113,7 +127,17 @@ func NewChatService(
|
||||
cache: cacheBackend,
|
||||
conversationCache: conversationCache,
|
||||
uploadService: uploadService,
|
||||
pushWorker: pushWorker,
|
||||
redisClient: redisClient,
|
||||
}
|
||||
|
||||
if len(versionLogRepo) > 0 && versionLogRepo[0] != nil {
|
||||
impl.versionLogRepo = versionLogRepo[0]
|
||||
impl.versionLogEnabled = true
|
||||
impl.versionLogMaxGap = 1000
|
||||
}
|
||||
|
||||
return impl
|
||||
}
|
||||
|
||||
func (s *chatServiceImpl) publishToUsers(userIDs []string, event string, payload any) {
|
||||
@@ -123,6 +147,115 @@ func (s *chatServiceImpl) publishToUsers(userIDs []string, event string, payload
|
||||
s.wsHub.PublishToUsers(userIDs, event, payload)
|
||||
}
|
||||
|
||||
// writeVersionLog 写入版本日志(异步,失败仅 warn)
|
||||
func (s *chatServiceImpl) writeVersionLog(ctx context.Context, userID, conversationID, changeType string) {
|
||||
if !s.versionLogEnabled || s.versionLogRepo == nil {
|
||||
return
|
||||
}
|
||||
maxV, err := s.versionLogRepo.GetMaxVersion(ctx, userID)
|
||||
if err != nil {
|
||||
zap.L().Warn("writeVersionLog: get max version failed", zap.String("userID", userID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
vlog := &model.ConversationVersionLog{
|
||||
UserID: userID,
|
||||
ConversationID: conversationID,
|
||||
Version: maxV + 1,
|
||||
ChangeType: changeType,
|
||||
}
|
||||
if err := s.versionLogRepo.Create(ctx, vlog); err != nil {
|
||||
zap.L().Warn("writeVersionLog: create failed", zap.String("userID", userID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// writeVersionLogBatch 批量写入版本日志(异步,失败仅 warn)
|
||||
func (s *chatServiceImpl) writeVersionLogBatch(ctx context.Context, userIDs []string, conversationID, changeType string) {
|
||||
if !s.versionLogEnabled || s.versionLogRepo == nil {
|
||||
return
|
||||
}
|
||||
logs := make([]*model.ConversationVersionLog, 0, len(userIDs))
|
||||
for _, uid := range userIDs {
|
||||
maxV, err := s.versionLogRepo.GetMaxVersion(ctx, uid)
|
||||
if err != nil {
|
||||
zap.L().Warn("writeVersionLogBatch: get max version failed", zap.String("userID", uid), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
logs = append(logs, &model.ConversationVersionLog{
|
||||
UserID: uid,
|
||||
ConversationID: conversationID,
|
||||
Version: maxV + 1,
|
||||
ChangeType: changeType,
|
||||
})
|
||||
}
|
||||
if len(logs) > 0 {
|
||||
if err := s.versionLogRepo.BatchCreate(ctx, logs); err != nil {
|
||||
zap.L().Warn("writeVersionLogBatch: batch create failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetSyncByVersion 增量同步:按版本号获取会话变更
|
||||
func (s *chatServiceImpl) GetSyncByVersion(ctx context.Context, userID string, sinceVersion int64, limit int) (*dto.SyncVersionResponse, error) {
|
||||
if !s.versionLogEnabled || s.versionLogRepo == nil {
|
||||
return nil, errors.New("version log sync is not enabled")
|
||||
}
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
currentVersion, err := s.versionLogRepo.GetMaxVersion(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current version: %w", err)
|
||||
}
|
||||
|
||||
resp := &dto.SyncVersionResponse{
|
||||
CurrentVersion: currentVersion,
|
||||
}
|
||||
|
||||
if currentVersion == 0 {
|
||||
resp.FullSync = true
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
gap := currentVersion - sinceVersion
|
||||
if gap > s.versionLogMaxGap {
|
||||
resp.FullSync = true
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
vlogs, err := s.versionLogRepo.GetByVersion(ctx, userID, sinceVersion, limit+1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get version logs: %w", err)
|
||||
}
|
||||
|
||||
hasMore := len(vlogs) > limit
|
||||
if hasMore {
|
||||
vlogs = vlogs[:limit]
|
||||
}
|
||||
|
||||
changes := make([]dto.SyncChangeItem, 0, len(vlogs))
|
||||
for _, vl := range vlogs {
|
||||
item := dto.SyncChangeItem{
|
||||
ConversationID: vl.ConversationID,
|
||||
ChangeType: vl.ChangeType,
|
||||
Version: vl.Version,
|
||||
}
|
||||
conv, cErr := s.repo.GetConversation(vl.ConversationID)
|
||||
if cErr == nil && conv != nil {
|
||||
item.LastMessageAt = conv.UpdatedAt.Unix()
|
||||
if maxSeq, sErr := s.conversationCache.GetConvMaxSeq(ctx, vl.ConversationID); sErr == nil {
|
||||
item.MaxSeq = maxSeq
|
||||
}
|
||||
}
|
||||
changes = append(changes, item)
|
||||
}
|
||||
|
||||
resp.Changes = changes
|
||||
resp.HasMore = hasMore
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GetOrCreateConversation 获取或创建私聊会话
|
||||
func (s *chatServiceImpl) GetOrCreateConversation(ctx context.Context, user1ID, user2ID string) (*model.Conversation, error) {
|
||||
conv, err := s.repo.GetOrCreatePrivateConversation(user1ID, user2ID)
|
||||
@@ -205,6 +338,8 @@ func (s *chatServiceImpl) DeleteConversationForSelf(ctx context.Context, convers
|
||||
s.conversationCache.InvalidateConversationList(userID)
|
||||
}
|
||||
|
||||
go s.writeVersionLog(context.Background(), userID, conversationID, "hide")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -387,6 +522,13 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
|
||||
// 获取会话中的参与者并发送消息
|
||||
participants, err := s.getParticipants(ctx, conversationID)
|
||||
if err == nil {
|
||||
// 版本日志:为所有参与者写入 new_msg
|
||||
pids := make([]string, 0, len(participants))
|
||||
for _, p := range participants {
|
||||
pids = append(pids, p.UserID)
|
||||
}
|
||||
go s.writeVersionLogBatch(context.Background(), pids, conversationID, "new_msg")
|
||||
|
||||
// 先更新未读计数器,再推送 WS 事件,确保事件中的 total_unread 已包含新消息
|
||||
// 注意:已移除 IncrementUnread,未读数完全由 maxSeq - readSeq 算术计算
|
||||
if s.conversationCache != nil {
|
||||
@@ -405,6 +547,32 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
|
||||
}
|
||||
}
|
||||
|
||||
// PushWorker 异步推送 or inline fallback
|
||||
if s.pushWorker != nil && s.pushWorker.IsEnabled() {
|
||||
streamMsg := &PushStreamMsg{
|
||||
ConversationID: conversationID,
|
||||
SenderID: senderID,
|
||||
MessageID: message.ID,
|
||||
ConvType: string(conv.Type),
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
if pubErr := PublishToPush(context.Background(), s.redisClient, "msg_push", streamMsg); pubErr != nil {
|
||||
zap.L().Warn("push stream publish failed, falling back to inline push",
|
||||
zap.String("conversationID", conversationID),
|
||||
zap.Error(pubErr),
|
||||
)
|
||||
s.inlinePush(ctx, conv, participants, senderID, conversationID, message)
|
||||
}
|
||||
} else {
|
||||
s.inlinePush(ctx, conv, participants, senderID, conversationID, message)
|
||||
}
|
||||
}
|
||||
|
||||
return message, nil
|
||||
}
|
||||
|
||||
// inlinePush 内联推送(原有逻辑,PushWorker 失败时的 fallback)
|
||||
func (s *chatServiceImpl) inlinePush(ctx context.Context, conv *model.Conversation, participants []*model.ConversationParticipant, senderID, conversationID string, message *model.Message) {
|
||||
targetIDs := make([]string, 0, len(participants))
|
||||
for _, p := range participants {
|
||||
if conv.Type == model.ConversationTypePrivate && p.UserID == senderID {
|
||||
@@ -438,7 +606,6 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if s.pushSvc != nil && len(participants) > 0 {
|
||||
sender := ChatMessageSender{ID: senderID}
|
||||
@@ -482,8 +649,6 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
|
||||
}(offlineTargetIDs, sender, groupID, groupAvatar)
|
||||
}
|
||||
}
|
||||
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func (s *chatServiceImpl) getConversation(ctx context.Context, conversationID string) (*model.Conversation, error) {
|
||||
|
||||
@@ -126,6 +126,7 @@ func NewGroupService(groupRepo repository.GroupRepository, userRepo repository.U
|
||||
convRepoAdapter,
|
||||
msgRepoAdapter,
|
||||
cache.DefaultConversationCacheSettings(),
|
||||
nil,
|
||||
)
|
||||
|
||||
return &groupService{
|
||||
|
||||
@@ -49,6 +49,7 @@ func NewMessageService(messageRepo repository.MessageRepository, cacheBackend ca
|
||||
convRepoAdapter,
|
||||
msgRepoAdapter,
|
||||
cache.DefaultConversationCacheSettings(),
|
||||
nil,
|
||||
)
|
||||
|
||||
return &MessageService{
|
||||
|
||||
381
internal/service/push_worker.go
Normal file
381
internal/service/push_worker.go
Normal file
@@ -0,0 +1,381 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/ws"
|
||||
"with_you/internal/repository"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// PushWorkerConfig 推送 Worker 配置
|
||||
type PushWorkerConfig struct {
|
||||
Enabled bool
|
||||
Stream string // Redis Stream 名称,默认 "msg_push"
|
||||
Group string // Consumer Group 名称,默认 "push_worker"
|
||||
BatchSize int // XREADGROUP 每次读取条数,默认 50
|
||||
PollTimeoutMs int // XREADGROUP BLOCK 超时 ms,默认 2000
|
||||
MaxRetries int // 消息最大重试次数,默认 3
|
||||
MaxStreamLen int64 // Stream MAXLEN,默认 100000
|
||||
IdleTimeoutMs int64 // XPENDING 空闲超时 ms,默认 30000
|
||||
}
|
||||
|
||||
// PushStreamMsg 推送到 Redis Stream 的消息结构
|
||||
type PushStreamMsg struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
SenderID string `json:"sender_id"`
|
||||
MessageID string `json:"message_id"`
|
||||
ConvType string `json:"conv_type"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// PushWorker 从 Redis Stream 消费消息并异步推送
|
||||
type PushWorker struct {
|
||||
rdb *redis.Client
|
||||
publisher ws.MessagePublisher
|
||||
pushSvc PushService
|
||||
msgRepo repository.MessageRepository
|
||||
userRepo repository.UserRepository
|
||||
config PushWorkerConfig
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
}
|
||||
|
||||
// NewPushWorker 创建推送 Worker
|
||||
func NewPushWorker(
|
||||
rdb *redis.Client,
|
||||
publisher ws.MessagePublisher,
|
||||
pushSvc PushService,
|
||||
msgRepo repository.MessageRepository,
|
||||
userRepo repository.UserRepository,
|
||||
config PushWorkerConfig,
|
||||
) *PushWorker {
|
||||
if config.Stream == "" {
|
||||
config.Stream = "msg_push"
|
||||
}
|
||||
if config.Group == "" {
|
||||
config.Group = "push_worker"
|
||||
}
|
||||
if config.BatchSize <= 0 {
|
||||
config.BatchSize = 50
|
||||
}
|
||||
if config.PollTimeoutMs <= 0 {
|
||||
config.PollTimeoutMs = 2000
|
||||
}
|
||||
if config.MaxRetries <= 0 {
|
||||
config.MaxRetries = 3
|
||||
}
|
||||
if config.MaxStreamLen <= 0 {
|
||||
config.MaxStreamLen = 100000
|
||||
}
|
||||
if config.IdleTimeoutMs <= 0 {
|
||||
config.IdleTimeoutMs = 30000
|
||||
}
|
||||
return &PushWorker{
|
||||
rdb: rdb,
|
||||
publisher: publisher,
|
||||
pushSvc: pushSvc,
|
||||
msgRepo: msgRepo,
|
||||
userRepo: userRepo,
|
||||
config: config,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled 是否启用
|
||||
func (w *PushWorker) IsEnabled() bool {
|
||||
return w != nil && w.config.Enabled
|
||||
}
|
||||
|
||||
// Start 启动 Worker
|
||||
func (w *PushWorker) Start(ctx context.Context) {
|
||||
if !w.config.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
// 创建 Consumer Group(忽略 BUSYGROUP 错误)
|
||||
err := w.rdb.XGroupCreateMkStream(ctx, w.config.Stream, w.config.Group, "0").Err()
|
||||
if err != nil {
|
||||
// BUSYGROUP 错误可以忽略
|
||||
zap.L().Debug("XGroupCreateMkStream", zap.Error(err))
|
||||
}
|
||||
|
||||
go w.consumeLoop()
|
||||
go w.claimPendingLoop()
|
||||
|
||||
zap.L().Info("PushWorker started",
|
||||
zap.String("stream", w.config.Stream),
|
||||
zap.String("group", w.config.Group),
|
||||
)
|
||||
}
|
||||
|
||||
// Stop 停止 Worker
|
||||
func (w *PushWorker) Stop() {
|
||||
if !w.config.Enabled {
|
||||
return
|
||||
}
|
||||
close(w.stopCh)
|
||||
// 等待两个循环退出
|
||||
<-w.doneCh
|
||||
<-w.doneCh
|
||||
zap.L().Info("PushWorker stopped")
|
||||
}
|
||||
|
||||
// consumeLoop 主消费循环
|
||||
func (w *PushWorker) consumeLoop() {
|
||||
defer func() { w.doneCh <- struct{}{} }()
|
||||
|
||||
consumerName := fmt.Sprintf("pw-%d", time.Now().UnixNano())
|
||||
args := &redis.XReadGroupArgs{
|
||||
Group: w.config.Group,
|
||||
Consumer: consumerName,
|
||||
Streams: []string{w.config.Stream, ">"},
|
||||
Count: int64(w.config.BatchSize),
|
||||
Block: time.Duration(w.config.PollTimeoutMs) * time.Millisecond,
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
results, err := w.rdb.XReadGroup(context.Background(), args).Result()
|
||||
if err != nil {
|
||||
if err == redis.Nil {
|
||||
continue
|
||||
}
|
||||
zap.L().Warn("PushWorker XReadGroup error", zap.Error(err))
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, stream := range results {
|
||||
for _, msg := range stream.Messages {
|
||||
if err := w.handleMsg(msg); err != nil {
|
||||
zap.L().Warn("PushWorker handle message error",
|
||||
zap.String("stream_id", msg.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
// ACK 无论成功失败(失败消息由 claimPendingLoop 重试)
|
||||
w.rdb.XAck(context.Background(), w.config.Stream, w.config.Group, msg.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// claimPendingLoop 定期认领超时未 ACK 的消息
|
||||
func (w *PushWorker) claimPendingLoop() {
|
||||
defer func() { w.doneCh <- struct{}{} }()
|
||||
|
||||
consumerName := fmt.Sprintf("pw-claim-%d", time.Now().UnixNano())
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
// 查询 pending 消息
|
||||
pendingResult, err := w.rdb.XPendingExt(context.Background(), &redis.XPendingExtArgs{
|
||||
Stream: w.config.Stream,
|
||||
Group: w.config.Group,
|
||||
Start: "-",
|
||||
End: "+",
|
||||
Count: int64(w.config.BatchSize),
|
||||
Idle: time.Duration(w.config.IdleTimeoutMs) * time.Millisecond,
|
||||
}).Result()
|
||||
if err != nil {
|
||||
if err == redis.Nil {
|
||||
continue
|
||||
}
|
||||
zap.L().Warn("PushWorker XPENDING error", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
for _, pending := range pendingResult {
|
||||
// 认领消息
|
||||
claimed, err := w.rdb.XClaim(context.Background(), &redis.XClaimArgs{
|
||||
Stream: w.config.Stream,
|
||||
Group: w.config.Group,
|
||||
Consumer: consumerName,
|
||||
MinIdle: time.Duration(w.config.IdleTimeoutMs) * time.Millisecond,
|
||||
Messages: []string{pending.ID},
|
||||
}).Result()
|
||||
if err != nil || len(claimed) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
retryCount := int(pending.RetryCount)
|
||||
for _, msg := range claimed {
|
||||
if retryCount >= w.config.MaxRetries {
|
||||
zap.L().Warn("PushWorker message exceeded max retries, deleting",
|
||||
zap.String("stream_id", msg.ID),
|
||||
zap.Int("retry_count", retryCount),
|
||||
)
|
||||
w.rdb.XDel(context.Background(), w.config.Stream, msg.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := w.handleMsg(msg); err != nil {
|
||||
zap.L().Warn("PushWorker claim retry handle error",
|
||||
zap.String("stream_id", msg.ID),
|
||||
zap.Int("retry_count", retryCount),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
w.rdb.XAck(context.Background(), w.config.Stream, w.config.Group, msg.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleMsg 处理一条 Stream 消息
|
||||
func (w *PushWorker) handleMsg(streamMsg redis.XMessage) error {
|
||||
data, ok := streamMsg.Values["data"]
|
||||
if !ok {
|
||||
return fmt.Errorf("missing data field in stream message %s", streamMsg.ID)
|
||||
}
|
||||
|
||||
var pushMsg PushStreamMsg
|
||||
dataStr, ok := data.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("data field is not string in stream message %s", streamMsg.ID)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(dataStr), &pushMsg); err != nil {
|
||||
return fmt.Errorf("unmarshal push message: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 加载消息
|
||||
message, err := w.msgRepo.GetMessageByID(pushMsg.MessageID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load message %s: %w", pushMsg.MessageID, err)
|
||||
}
|
||||
|
||||
// 加载会话
|
||||
conv, err := w.msgRepo.GetConversation(pushMsg.ConversationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load conversation %s: %w", pushMsg.ConversationID, err)
|
||||
}
|
||||
|
||||
// 加载参与者
|
||||
participants, err := w.msgRepo.GetConversationParticipants(pushMsg.ConversationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load participants for %s: %w", pushMsg.ConversationID, err)
|
||||
}
|
||||
|
||||
// ---- WS Push ----
|
||||
targetIDs := make([]string, 0, len(participants))
|
||||
for _, p := range participants {
|
||||
if conv.Type == model.ConversationTypePrivate && p.UserID == pushMsg.SenderID {
|
||||
continue
|
||||
}
|
||||
targetIDs = append(targetIDs, p.UserID)
|
||||
}
|
||||
|
||||
detailType := "private"
|
||||
if conv.Type == model.ConversationTypeGroup {
|
||||
detailType = "group"
|
||||
}
|
||||
|
||||
if len(targetIDs) > 20 && conv.Type == model.ConversationTypeGroup {
|
||||
w.publisher.PublishToUsersConcurrent(targetIDs, "chat_message", map[string]any{
|
||||
"detail_type": detailType,
|
||||
"message": dto.ConvertMessageToResponse(message),
|
||||
})
|
||||
} else {
|
||||
w.publisher.PublishToUsers(targetIDs, "chat_message", map[string]any{
|
||||
"detail_type": detailType,
|
||||
"message": dto.ConvertMessageToResponse(message),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- WS unread count push ----
|
||||
for _, p := range participants {
|
||||
if p.UserID == pushMsg.SenderID {
|
||||
continue
|
||||
}
|
||||
// PushWorker doesn't have conversationCache, skip unread count push here
|
||||
// The inline push in chat_service still handles this when push worker is disabled
|
||||
}
|
||||
|
||||
// ---- JPush for offline users ----
|
||||
if w.pushSvc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sender := ChatMessageSender{ID: pushMsg.SenderID}
|
||||
if senderUser, sErr := w.userRepo.GetByID(pushMsg.SenderID); sErr == nil {
|
||||
sender.Name = senderUser.Nickname
|
||||
sender.Avatar = senderUser.Avatar
|
||||
}
|
||||
|
||||
convName := ""
|
||||
groupID := ""
|
||||
groupAvatar := ""
|
||||
if conv.Type == model.ConversationTypeGroup && conv.Group != nil {
|
||||
convName = conv.Group.Name
|
||||
groupID = conv.Group.ID
|
||||
groupAvatar = conv.Group.Avatar
|
||||
}
|
||||
|
||||
allTargetIDs := make([]string, 0, len(participants))
|
||||
for _, p := range participants {
|
||||
if p.UserID == pushMsg.SenderID {
|
||||
continue
|
||||
}
|
||||
allTargetIDs = append(allTargetIDs, p.UserID)
|
||||
}
|
||||
|
||||
var offlineTargetIDs []string
|
||||
if w.publisher != nil && len(allTargetIDs) > 0 {
|
||||
_, offlineTargetIDs = w.publisher.FilterOnline(allTargetIDs)
|
||||
} else {
|
||||
offlineTargetIDs = allTargetIDs
|
||||
}
|
||||
|
||||
for _, uid := range offlineTargetIDs {
|
||||
if pushErr := w.pushSvc.PushChatMessage(ctx, uid, pushMsg.ConversationID, &sender, conv.Type, convName, message, groupID, groupAvatar); pushErr != nil {
|
||||
zap.L().Debug("push worker: JPush chat message failed",
|
||||
zap.String("userID", uid),
|
||||
zap.String("conversationID", pushMsg.ConversationID),
|
||||
zap.Error(pushErr),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PublishToPush 将消息发布到推送 Stream
|
||||
func PublishToPush(ctx context.Context, rdb *redis.Client, stream string, msg *PushStreamMsg) error {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal push message: %w", err)
|
||||
}
|
||||
|
||||
return rdb.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: stream,
|
||||
MaxLen: 100000,
|
||||
Approx: true,
|
||||
ID: "*",
|
||||
Values: map[string]any{
|
||||
"data": string(data),
|
||||
},
|
||||
}).Err()
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"with_you/internal/repository"
|
||||
"with_you/internal/service"
|
||||
|
||||
redisPkg "github.com/redis/go-redis/v9"
|
||||
"github.com/google/wire"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
@@ -49,6 +50,12 @@ var InfrastructureSet = wire.NewSet(
|
||||
// WebSocket MessagePublisher
|
||||
ProvideWSMessagePublisher,
|
||||
|
||||
// Seq 预分配管理器
|
||||
ProvideSeqBufferManager,
|
||||
|
||||
// 推送 Worker
|
||||
ProvidePushWorker,
|
||||
|
||||
// 外部服务客户端
|
||||
ProvideOpenAIClient,
|
||||
ProvideTencentClient,
|
||||
@@ -275,3 +282,47 @@ func ProvideLogger() *zap.Logger {
|
||||
zap.ReplaceGlobals(logger)
|
||||
return logger
|
||||
}
|
||||
|
||||
// ProvideSeqBufferManager 提供 Seq 预分配管理器
|
||||
func ProvideSeqBufferManager(cfg *config.Config, redisClient *redis.Client, cacheBackend cache.Cache) *cache.SeqBufferManager {
|
||||
seqCfg := cache.SeqBufferConfig{
|
||||
Enabled: cfg.SeqBuffer.Enabled,
|
||||
PrivateBufferSize: cfg.SeqBuffer.PrivateBufferSize,
|
||||
GroupBufferSize: cfg.SeqBuffer.GroupBufferSize,
|
||||
LockTimeoutMs: cfg.SeqBuffer.LockTimeoutMs,
|
||||
MaxRetries: cfg.SeqBuffer.MaxRetries,
|
||||
}
|
||||
if !seqCfg.Enabled {
|
||||
return cache.NewSeqBufferManager(nil, seqCfg, nil, nil)
|
||||
}
|
||||
return cache.NewSeqBufferManagerFromRedisPkg(redisClient, seqCfg, nil, nil)
|
||||
}
|
||||
|
||||
// ProvidePushWorker 提供推送 Worker(Redis Stream 异步推送)
|
||||
func ProvidePushWorker(
|
||||
cfg *config.Config,
|
||||
redisClient *redis.Client,
|
||||
publisher ws.MessagePublisher,
|
||||
pushSvc service.PushService,
|
||||
msgRepo repository.MessageRepository,
|
||||
userRepo repository.UserRepository,
|
||||
) *service.PushWorker {
|
||||
pushCfg := service.PushWorkerConfig{
|
||||
Enabled: cfg.PushWorker.Enabled,
|
||||
Stream: cfg.PushWorker.Stream,
|
||||
Group: cfg.PushWorker.Group,
|
||||
BatchSize: cfg.PushWorker.BatchSize,
|
||||
PollTimeoutMs: cfg.PushWorker.PollTimeoutMs,
|
||||
MaxRetries: cfg.PushWorker.MaxRetries,
|
||||
MaxStreamLen: cfg.PushWorker.MaxStreamLen,
|
||||
IdleTimeoutMs: cfg.PushWorker.IdleTimeoutMs,
|
||||
}
|
||||
if !pushCfg.Enabled {
|
||||
return service.NewPushWorker(nil, nil, nil, nil, nil, pushCfg)
|
||||
}
|
||||
var rdb *redisPkg.Client
|
||||
if redisClient != nil {
|
||||
rdb = redisClient.GetClient()
|
||||
}
|
||||
return service.NewPushWorker(rdb, publisher, pushSvc, msgRepo, userRepo, pushCfg)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,9 @@ var RepositorySet = wire.NewSet(
|
||||
|
||||
// 二手交易相关
|
||||
repository.NewTradeRepository,
|
||||
|
||||
// 版本日志同步
|
||||
repository.NewConversationVersionLogRepository,
|
||||
)
|
||||
|
||||
// ProvideUserActivityRepository 提供用户活跃数据仓储
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/google/wire"
|
||||
redislib "github.com/redis/go-redis/v9"
|
||||
redisPkg "github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -212,8 +212,21 @@ func ProvideChatService(
|
||||
cacheBackend cache.Cache,
|
||||
uploadService *service.UploadService,
|
||||
pushService service.PushService,
|
||||
seqBufferMgr *cache.SeqBufferManager,
|
||||
pushWorker *service.PushWorker,
|
||||
redisClient *redis.Client,
|
||||
versionLogRepo repository.ConversationVersionLogRepository,
|
||||
cfg *config.Config,
|
||||
) service.ChatService {
|
||||
return service.NewChatService(messageRepo, userRepo, nil, publisher, cacheBackend, uploadService, pushService)
|
||||
var vlogOpt []repository.ConversationVersionLogRepository
|
||||
if cfg.VersionLog.Enabled {
|
||||
vlogOpt = []repository.ConversationVersionLogRepository{versionLogRepo}
|
||||
}
|
||||
var rdb *redisPkg.Client
|
||||
if redisClient != nil {
|
||||
rdb = redisClient.GetClient()
|
||||
}
|
||||
return service.NewChatService(messageRepo, userRepo, nil, publisher, cacheBackend, uploadService, pushService, seqBufferMgr, pushWorker, rdb, vlogOpt...)
|
||||
}
|
||||
|
||||
// ProvideScheduleService 提供日程服务
|
||||
@@ -398,7 +411,7 @@ func ProvideLogService(
|
||||
|
||||
// ProvideQRCodeLoginService 提供二维码登录服务
|
||||
func ProvideHotRankWorker(cfg *config.Config, postRepo repository.PostRepository, channelRepo repository.ChannelRepository, cacheBackend cache.Cache, redisClient *redis.Client) *service.HotRankWorker {
|
||||
var rdb *redislib.Client
|
||||
var rdb *redisPkg.Client
|
||||
if redisClient != nil {
|
||||
rdb = redisClient.GetClient()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user