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