using Application.Abstractions.Data; using Application.Abstractions.Stocks; using Microsoft.EntityFrameworkCore; using StackExchange.Redis; namespace Infrastructure.Stocks; /// /// Redis Sorted Set 기반 종목 트렌딩 캐시 (d2 §④·§⑥) — RedisChatLeaderboard/PaperLeaderboardCache 패턴 복제. /// 점수 = 최근 24h PostStockTag 언급량 × RecentWeight + StockBoardStats.Posts(총 글 수, tie-breaker). /// TTL 10분 — 만료 시 다음 호출이 DB 집계 1회 후 재시드. StockMentionAggregator 배치가 없을 때도 lazy 로 동작. /// internal sealed class RedisStockBoardTrendingCache(IConnectionMultiplexer redis) : IStockBoardTrendingCache { private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10); // 최근성 가중 — 24h 언급 1건이 총 글 10건과 동일 가중. private const int RecentWeight = 10; private const string Key = "stock-trending"; public async Task> GetTopAsync(int count, IAppDbContext db, CancellationToken ct = default) { if (count <= 0) { return []; } var r = redis.GetDatabase(); await EnsureSeededAsync(r, db, ct); var entries = await r.SortedSetRangeByRankWithScoresAsync(Key, 0, count - 1, Order.Descending); var rows = new List(entries.Length); foreach (var entry in entries) { var code = (string?)entry.Element; if (!string.IsNullOrEmpty(code)) { rows.Add(new StockTrendingEntry(code, (int)entry.Score)); } } return rows; } public async Task InvalidateAsync(CancellationToken ct = default) { var r = redis.GetDatabase(); await r.KeyDeleteAsync(Key); } /// 캐시 미스 → DB 집계 후 ZADD 재시드. TTL 동안 후속 호출은 Redis only. private static async Task EnsureSeededAsync(IDatabase r, IAppDbContext db, CancellationToken ct) { if (await r.KeyExistsAsync(Key)) { return; } var since = DateTime.UtcNow.AddHours(-24); // 최근 24h 언급량 (코드별 count) var recentMentions = await db.PostStockTag.AsNoTracking() .Where(c => c.CreatedAt >= since) .GroupBy(c => c.StockCode) .Select(g => new { Code = g.Key, Count = g.Count() }) .ToListAsync(ct); // 총 글 수 (StockBoardStats 파티션) var totals = await db.StockBoardStats.AsNoTracking() .Select(c => new { c.StockCode, c.Posts }) .ToListAsync(ct); var scores = new Dictionary(StringComparer.Ordinal); foreach (var t in totals) { scores[t.StockCode] = t.Posts; } foreach (var m in recentMentions) { scores.TryGetValue(m.Code, out var baseScore); scores[m.Code] = baseScore + (m.Count * RecentWeight); } if (scores.Count == 0) { return; } var entries = scores .Where(kv => kv.Value > 0) .Select(kv => new SortedSetEntry(kv.Key, kv.Value)) .ToArray(); if (entries.Length == 0) { return; } await r.SortedSetAddAsync(Key, entries); await r.KeyExpireAsync(Key, CacheTtl); } }