using Application.Abstractions.Data; using Application.Abstractions.Paper; using Application.Helpers; using StackExchange.Redis; namespace Infrastructure.Paper; /// /// Redis Sorted Set 기반 모의투자 리더보드 캐시 (d4 M3 리더보드 백엔드 구현) — RedisChatLeaderboard 패턴 복제. /// (sort, period) 조합별 키. TTL 25h — 만료 시 다음 호출이 DB 지표 산출 후 재시드. /// return/winrate 는 ZREVRANGE(내림차순 = 높을수록 상위), mdd 는 ZRANGE(오름차순 = 낮을수록 상위). /// internal sealed class PaperLeaderboardCache(IConnectionMultiplexer redis) : IPaperLeaderboardCache { private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(25); private static string Key(PaperLeaderboardSort sort, PaperLeaderboardPeriod period) { var s = sort switch { PaperLeaderboardSort.Return => "return", PaperLeaderboardSort.WinRate => "winrate", PaperLeaderboardSort.Mdd => "mdd", _ => "return" }; var p = period switch { PaperLeaderboardPeriod.Week => "week", PaperLeaderboardPeriod.Month => "month", PaperLeaderboardPeriod.All => "all", _ => "all" }; return $"paper-lb:{s}:{p}"; } public async Task> GetPageAsync( PaperLeaderboardSort sort, PaperLeaderboardPeriod period, int offset, int count, IAppDbContext db, int minFillsForRank, CancellationToken ct = default) { var r = redis.GetDatabase(); var key = Key(sort, period); await EnsureSeededAsync(r, key, sort, period, db, minFillsForRank, ct); var ascending = PaperLeaderboardMetrics.IsAscending(sort); var order = ascending ? Order.Ascending : Order.Descending; var stop = offset + count - 1; var entries = await r.SortedSetRangeByRankWithScoresAsync(key, offset, stop, order); var rows = new List(entries.Length); for (var i = 0; i < entries.Length; i++) { if (int.TryParse((string?)entries[i].Element, out var memberID)) { rows.Add(new PaperLeaderboardRow(memberID, (int)entries[i].Score, offset + i)); } } return rows; } public async Task GetMyRankAsync( PaperLeaderboardSort sort, PaperLeaderboardPeriod period, int memberID, IAppDbContext db, int minFillsForRank, CancellationToken ct = default) { var r = redis.GetDatabase(); var key = Key(sort, period); await EnsureSeededAsync(r, key, sort, period, db, minFillsForRank, ct); var member = (RedisValue)memberID.ToString(); var score = await r.SortedSetScoreAsync(key, member); if (!score.HasValue) { return null; } var ascending = PaperLeaderboardMetrics.IsAscending(sort); var order = ascending ? Order.Ascending : Order.Descending; var rank = await r.SortedSetRankAsync(key, member, order); var total = await r.SortedSetLengthAsync(key); if (!rank.HasValue) { return null; } return new PaperLeaderboardMyRank(rank.Value, (int)score.Value, total); } /// 캐시 미스 → DB 지표 산출 후 ZADD 재시드. TTL 동안 후속 호출은 Redis only. private static async Task EnsureSeededAsync( IDatabase r, string key, PaperLeaderboardSort sort, PaperLeaderboardPeriod period, IAppDbContext db, int minFillsForRank, CancellationToken ct) { if (await r.KeyExistsAsync(key)) { return; } var metrics = await PaperLeaderboardMetrics.ComputeAllAsync(db, period, minFillsForRank, ct); if (metrics.Count == 0) { return; } var entries = metrics .Select(c => new SortedSetEntry(c.MemberID.ToString(), PaperLeaderboardMetrics.Score(c, sort))) .ToArray(); await r.SortedSetAddAsync(key, entries); await r.KeyExpireAsync(key, CacheTtl); } }