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);
}
}