RedisChatPresenceStore.java

package com.ecommerce.chat.infrastructure.realtime;

import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Set;

@Component
@ConditionalOnProperty(
        prefix = "ecommerce.chat.realtime",
        name = "enabled",
        havingValue = "true")
public class RedisChatPresenceStore {

    private static final DefaultRedisScript<Long> REFRESH_SCRIPT = new DefaultRedisScript<>("""
            local time = redis.call('TIME')
            local now = (time[1] * 1000) + math.floor(time[2] / 1000)
            local presenceTtl = tonumber(ARGV[2])
            redis.call('SET', KEYS[1], tostring(now), 'PX', presenceTtl)
            redis.call('ZADD', KEYS[2], now + presenceTtl, ARGV[1])
            redis.call('PEXPIRE', KEYS[2], tonumber(ARGV[3]))
            return now
            """, Long.class);
    @SuppressWarnings("rawtypes")
    private static final DefaultRedisScript<List> ONLINE_NODES_SCRIPT =
            new DefaultRedisScript<>("""
                    local time = redis.call('TIME')
                    local now = (time[1] * 1000) + math.floor(time[2] / 1000)
                    redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)
                    local candidates = redis.call('ZRANGEBYSCORE', KEYS[1], now + 1, '+inf')
                    local online = {}
                    for _, nodeId in ipairs(candidates) do
                        if redis.call('EXISTS', ARGV[1] .. nodeId) == 1 then
                            table.insert(online, nodeId)
                        else
                            redis.call('ZREM', KEYS[1], nodeId)
                        end
                    end
                    return online
                    """, List.class);

    private final StringRedisTemplate redisTemplate;
    private final ChatRealtimeProperties properties;

    public RedisChatPresenceStore(
            StringRedisTemplate redisTemplate,
            ChatRealtimeProperties properties) {
        this.redisTemplate = redisTemplate;
        this.properties = properties;
    }

    public void register(Long userId) {
        refreshUser(userId);
    }

    public void refresh(Set<Long> userIds) {
        if (userIds.isEmpty()) {
            return;
        }
        for (Long userId : userIds) {
            refreshUser(userId);
        }
    }

    public void unregister(Long userId) {
        redisTemplate.opsForZSet().remove(userRoutesKey(userId), properties.nodeId());
    }

    public Set<String> onlineNodes(Long userId) {
        @SuppressWarnings("unchecked")
        List<String> candidates = redisTemplate.execute(
                ONLINE_NODES_SCRIPT,
                List.of(userRoutesKey(userId)),
                nodeKeyPrefix());
        if (candidates == null || candidates.isEmpty()) {
            return Set.of();
        }
        return Set.copyOf(candidates);
    }

    public boolean isNodeOnline(String nodeId) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(nodeKey(nodeId)));
    }

    private void refreshUser(Long userId) {
        redisTemplate.execute(
                REFRESH_SCRIPT,
                List.of(
                nodeKey(properties.nodeId()),
                        userRoutesKey(userId)),
                properties.nodeId(),
                Long.toString(properties.presenceTtl().toMillis()),
                Long.toString(properties.presenceTtl().multipliedBy(3).toMillis()));
    }

    private String nodeKey(String nodeId) {
        return nodeKeyPrefix() + nodeId;
    }

    private String nodeKeyPrefix() {
        return properties.redisKeyPrefix() + "node:";
    }

    private String userRoutesKey(Long userId) {
        return properties.redisKeyPrefix() + "user:" + userId + ":nodes";
    }
}