feat(chat): implement sequence-based unread count tracking
Introduce a new mechanism for tracking read positions using message sequences (hasReadSeq), similar to OpenIM, to allow for O(1) unread count calculations. - Implement `UserReadSeq` caching in Redis with Lua scripts to prevent sequence regression (ensuring updates only occur if the new sequence is greater than the current one). - Update `ConversationCache` to support sequence-based unread count computation (`maxSeq - hasReadSeq`). - Refactor `MessageRepository` to use conditional updates for `last_read_seq` in the database. - Update `ChatService` to automatically update the sender's read sequence when sending a message. - Optimize `MarkAsRead` logic to update both database and Redis sequence caches and refine notification targets for private vs group chats. - Update `jpush` client to use pointer types for boolean fields to properly handle optional values in JSON.
This commit is contained in:
166
internal/cache/conversation_cache.go
vendored
166
internal/cache/conversation_cache.go
vendored
@@ -484,6 +484,172 @@ func (c *ConversationCache) InvalidateUnreadCount(userID, convID string) {
|
||||
c.cache.Delete(UnreadDetailKey(userID, convID))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 已读位置缓存(OpenIM 风格 hasReadSeq)
|
||||
// ============================================================
|
||||
|
||||
// readSeqExpire 已读位置缓存 TTL
|
||||
const readSeqExpire = 30 * 24 * time.Hour
|
||||
|
||||
// SetUserReadSeq 设置用户在某会话的已读位置(防回退:仅当新值大于当前值时更新)
|
||||
func (c *ConversationCache) SetUserReadSeq(ctx context.Context, convID, userID string, seq int64) error {
|
||||
key := UserReadSeqKey(convID, userID)
|
||||
|
||||
redisCache, ok := c.cache.(*RedisCache)
|
||||
if !ok {
|
||||
// 非 Redis 模式,直接设置
|
||||
c.cache.Set(key, seq, readSeqExpire)
|
||||
return nil
|
||||
}
|
||||
|
||||
rdb := redisCache.GetRedisClient().GetClient()
|
||||
nKey := normalizeKey(key)
|
||||
// Lua 脚本:仅当新 seq > 当前值时才更新,防回退
|
||||
script := redis.NewScript(`
|
||||
local current = tonumber(redis.call('GET', KEYS[1]))
|
||||
if current and current >= tonumber(ARGV[1]) then
|
||||
return 0
|
||||
end
|
||||
redis.call('SET', KEYS[1], ARGV[1])
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[2])
|
||||
return 1
|
||||
`)
|
||||
_, err := script.Run(ctx, rdb, []string{nKey}, seq, int64(readSeqExpire.Seconds())).Result()
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUserReadSeq 获取用户在某会话的已读位置
|
||||
func (c *ConversationCache) GetUserReadSeq(ctx context.Context, convID, userID string) (int64, error) {
|
||||
key := UserReadSeqKey(convID, userID)
|
||||
|
||||
redisCache, ok := c.cache.(*RedisCache)
|
||||
if !ok {
|
||||
raw, ok := c.cache.Get(key)
|
||||
if !ok {
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case int64:
|
||||
return v, nil
|
||||
case string:
|
||||
var n int64
|
||||
fmt.Sscanf(v, "%d", &n)
|
||||
return n, nil
|
||||
}
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
|
||||
rdb := redisCache.GetRedisClient().GetClient()
|
||||
nKey := normalizeKey(key)
|
||||
val, err := rdb.Get(ctx, nKey).Int64()
|
||||
if err != nil {
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// GetUserReadSeqs 批量获取用户在多个会话的已读位置
|
||||
func (c *ConversationCache) GetUserReadSeqs(ctx context.Context, userID string, 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.GetUserReadSeq(ctx, convID, userID); 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(UserReadSeqKey(convID, userID))
|
||||
}
|
||||
|
||||
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)
|
||||
result[convIDs[i]] = seq
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetConvMaxSeq 获取会话的最大 seq(复用 msg_seq 计数器,读取当前值)
|
||||
func (c *ConversationCache) GetConvMaxSeq(ctx context.Context, convID string) (int64, error) {
|
||||
seqKey := MessageSeqKey(convID)
|
||||
|
||||
redisCache, ok := c.cache.(*RedisCache)
|
||||
if !ok {
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
|
||||
rdb := redisCache.GetRedisClient().GetClient()
|
||||
nKey := normalizeKey(seqKey)
|
||||
val, err := rdb.Get(ctx, nKey).Int64()
|
||||
if err != nil {
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// ComputeUnreadCount 计算单个会话未读数:maxSeq - hasReadSeq
|
||||
func (c *ConversationCache) ComputeUnreadCount(ctx context.Context, convID, userID string) (int64, error) {
|
||||
maxSeq, err := c.GetConvMaxSeq(ctx, convID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
readSeq, err := c.GetUserReadSeq(ctx, convID, userID)
|
||||
if err != nil {
|
||||
// 未读到已读位置,视为 0,返回 maxSeq
|
||||
return maxSeq, nil
|
||||
}
|
||||
unread := maxSeq - readSeq
|
||||
if unread < 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return unread, nil
|
||||
}
|
||||
|
||||
// ComputeAllUnreadCount 计算所有会话未读总数:sum(maxSeq - hasReadSeq)
|
||||
func (c *ConversationCache) ComputeAllUnreadCount(ctx context.Context, userID string, convIDs []string, maxSeqs map[string]int64) (int64, error) {
|
||||
readSeqs, err := c.GetUserReadSeqs(ctx, userID, convIDs)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var total int64
|
||||
for _, convID := range convIDs {
|
||||
maxSeq, ok := maxSeqs[convID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
readSeq, hasRead := readSeqs[convID]
|
||||
if !hasRead {
|
||||
total += maxSeq
|
||||
continue
|
||||
}
|
||||
unread := maxSeq - readSeq
|
||||
if unread > 0 {
|
||||
total += unread
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// IncrementUnread 递增用户在某会话的未读数(Redis Hash + 总数计数器)
|
||||
func (c *ConversationCache) IncrementUnread(ctx context.Context, userID, convID string) error {
|
||||
hashKey := UnreadHashKey(userID)
|
||||
|
||||
Reference in New Issue
Block a user