feat(chat): implement batch mark-as-read and message sync data
All checks were successful
Build Backend / build (push) Successful in 2m2s
Build Backend / build-docker (push) Successful in 1m41s

Refactor unread count logic to use arithmetic calculation (maxSeq - readSeq)
instead of manual incrementing/decrementing. This simplifies the cache
management and improves consistency.

New features:
- Batch mark-as-read functionality for multiple conversations.
- Lightweight sync data endpoint to retrieve conversation metadata
  (maxSeq and last message timestamp) for client synchronization.
- Optimized batch retrieval of conversation max sequences using Redis MGet.

Technical changes:
- Deprecated `IncrementUnread` and `ClearUnread` in `ConversationCache`.
- Added `GetConvMaxSeqBatch` to reduce network round-trips.
- Added `HandleMarkReadAll` and `HandleGetSyncData` handlers.
- Updated `ChatService` to support batch operations and sync data retrieval.
This commit is contained in:
2026-05-12 18:04:59 +08:00
parent 2f2bbc646e
commit 8d7e8c427b
5 changed files with 236 additions and 175 deletions

View File

@@ -606,6 +606,48 @@ func (c *ConversationCache) GetConvMaxSeq(ctx context.Context, convID string) (i
return val, nil
}
// GetConvMaxSeqBatch 批量获取多个会话的 maxSeqMGet 减少网络往返)
func (c *ConversationCache) GetConvMaxSeqBatch(ctx context.Context, convIDs []string) (map[string]int64, error) {
if len(convIDs) == 0 {
return nil, nil
}
redisCache, ok := c.cache.(*RedisCache)
if !ok {
result := make(map[string]int64, len(convIDs))
for _, convID := range convIDs {
if seq, err := c.GetConvMaxSeq(ctx, convID); err == nil {
result[convID] = seq
}
}
return result, nil
}
rdb := redisCache.GetRedisClient().GetClient()
keys := make([]string, len(convIDs))
for i, convID := range convIDs {
keys[i] = normalizeKey(MessageSeqKey(convID))
}
vals, err := rdb.MGet(ctx, keys...).Result()
if err != nil {
return nil, err
}
result := make(map[string]int64, len(convIDs))
for i, val := range vals {
if val == nil {
continue
}
var seq int64
fmt.Sscanf(fmt.Sprint(val), "%d", &seq)
if seq > 0 {
result[convIDs[i]] = seq
}
}
return result, nil
}
// ComputeUnreadCount 计算单个会话未读数maxSeq - hasReadSeq
func (c *ConversationCache) ComputeUnreadCount(ctx context.Context, convID, userID string) (int64, error) {
maxSeq, err := c.GetConvMaxSeq(ctx, convID)
@@ -650,128 +692,34 @@ func (c *ConversationCache) ComputeAllUnreadCount(ctx context.Context, userID st
return total, nil
}
// IncrementUnread 递增用户在某会话的未读数(Redis Hash + 总数计数器
// IncrementUnread 递增用户在某会话的未读数(已废弃:改用 seq 算术计算
// Deprecated: Unread count is now computed via arithmetic (maxSeq - readSeq).
func (c *ConversationCache) IncrementUnread(ctx context.Context, userID, convID string) error {
hashKey := UnreadHashKey(userID)
totalKey := UnreadTotalKey(userID)
_, err := c.cache.HIncrBy(ctx, hashKey, convID, 1)
if err != nil {
return fmt.Errorf("hincrby unread failed: %w", err)
}
_, err = c.cache.Incr(ctx, totalKey)
if err != nil {
return fmt.Errorf("incr unread total failed: %w", err)
}
_ = c.cache.Expire(ctx, hashKey, c.settings.UnreadTTL*10)
_ = c.cache.Expire(ctx, totalKey, c.settings.UnreadTTL*10)
return nil
}
// ClearUnread 清零用户在某会话的未读数(原子操作,防止与 IncrementUnread 竞态
// ClearUnread 清零用户在某会话的未读数(已废弃:改用 seq 算术计算
// Deprecated: No longer needed. Unread is computed from seq arithmetic.
func (c *ConversationCache) ClearUnread(ctx context.Context, userID, convID string) error {
hashKey := UnreadHashKey(userID)
totalKey := UnreadTotalKey(userID)
redisCache, ok := c.cache.(*RedisCache)
if !ok {
// miniredis/内存模式:保持原逻辑(非原子,但无并发竞态风险)
currentStr, err := c.cache.HGet(ctx, hashKey, convID)
if err != nil {
currentStr = "0"
}
current := int64(0)
if v, parseErr := fmt.Sscanf(currentStr, "%d", &current); parseErr != nil || v != 1 {
current = 0
}
if err := c.cache.HSet(ctx, hashKey, convID, "0"); err != nil {
return fmt.Errorf("hset clear unread failed: %w", err)
}
if current > 0 {
c.cache.IncrBySeq(ctx, totalKey, -current)
}
return nil
}
rdb := redisCache.GetRedisClient().GetClient()
nHashKey := normalizeKey(hashKey)
nTotalKey := normalizeKey(totalKey)
script := redis.NewScript(`
local current = tonumber(redis.call('HGET', KEYS[1], ARGV[1]))
if current == nil or current <= 0 then
return 0
end
redis.call('HSET', KEYS[1], ARGV[1], '0')
local newTotal = tonumber(redis.call('INCRBY', KEYS[2], -current))
if newTotal < 0 then
redis.call('SET', KEYS[2], '0')
end
return current
`)
_, err := script.Run(ctx, rdb, []string{nHashKey, nTotalKey}, convID).Result()
if err != nil {
return fmt.Errorf("lua clear unread failed: %w", err)
}
return nil
}
// GetUnreadCountFromHash 从 Redis Hash 获取单个会话未读数
// GetUnreadCountFromHash 从 Redis Hash 获取单个会话未读数(已废弃)
// Deprecated: Use ComputeUnreadCount instead.
func (c *ConversationCache) GetUnreadCountFromHash(ctx context.Context, userID, convID string) (int64, error) {
hashKey := UnreadHashKey(userID)
val, err := c.cache.HGet(ctx, hashKey, convID)
if err != nil {
return 0, err
}
var count int64
fmt.Sscanf(val, "%d", &count)
return count, nil
return 0, ErrKeyNotFound
}
// GetAllUnreadCounts 从 Redis Hash 获取用户所有会话的未读数
// GetAllUnreadCounts 从 Redis Hash 获取用户所有会话的未读数(已废弃)
// Deprecated: Use ComputeAllUnreadCount instead.
func (c *ConversationCache) GetAllUnreadCounts(ctx context.Context, userID string) (map[string]int64, error) {
hashKey := UnreadHashKey(userID)
all, err := c.cache.HGetAll(ctx, hashKey)
if err != nil {
return nil, err
}
result := make(map[string]int64, len(all))
for k, v := range all {
var count int64
fmt.Sscanf(v, "%d", &count)
result[k] = count
}
return result, nil
return nil, ErrKeyNotFound
}
// GetTotalUnread 从 Redis 获取用户总未读数
// GetTotalUnread 从 Redis 获取用户总未读数(已废弃)
// Deprecated: Use ComputeAllUnreadCount instead.
func (c *ConversationCache) GetTotalUnread(ctx context.Context, userID string) (int64, error) {
totalKey := UnreadTotalKey(userID)
redisCache, ok := c.cache.(*RedisCache)
if ok {
val, err := redisCache.GetRedisClient().GetClient().Get(ctx, normalizeKey(totalKey)).Int64()
if err != nil {
return 0, nil
}
return val, nil
}
raw, ok := c.cache.Get(totalKey)
if !ok {
return 0, nil
}
switch v := raw.(type) {
case int64:
return v, nil
case string:
var n int64
fmt.Sscanf(v, "%d", &n)
return n, nil
}
return 0, nil
return 0, ErrKeyNotFound
}
// GetNextSeq 获取会话的下一个 seq 值(原子递增)