Explorar el Código

refactor(paper): 활동토큰 일일상한 KST 경계 + 리더보드 배치 warm-refresh

- ActivityTokenGranter: 일일상한 집계를 서버 로컬시각 → KST 일 경계(자정~
  익일 자정, UTC 환산)로. 서버 TZ 무관하게 한국 하루 기준 정확 집계.
- IPaperLeaderboardCache.InvalidateAllAsync + PaperLeaderboardCache 구현
  (paper-lb:{sort}:{period} 전체 삭제). PaperFillService 스냅샷 직후 호출 →
  다음 리더보드 조회가 최신 스냅샷으로 재시드(warm-refresh, 기존 lazy 대체).

빌드 0 에러, 테스트 101/101.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 hace 2 semanas
padre
commit
0fd87b1f6e

+ 5 - 0
Application/Abstractions/Paper/IPaperLeaderboardCache.cs

@@ -39,4 +39,9 @@ public interface IPaperLeaderboardCache
         IAppDbContext db,
         int minFillsForRank,
         CancellationToken ct = default);
+
+    /// <summary>
+    /// 전체 (sort, period) 캐시 무효화 — 스냅샷 배치 직후 호출하면 다음 조회가 최신 스냅샷으로 재시드된다 (warm-refresh).
+    /// </summary>
+    Task InvalidateAllAsync(CancellationToken ct = default);
 }

+ 20 - 1
Application/Helpers/ActivityTokenGranter.cs

@@ -17,6 +17,8 @@ public static class ActivityTokenGranter
     public const string PostReason = "ACTIVITY_POST";
     public const string CommentReason = "ACTIVITY_COMMENT";
 
+    private static readonly TimeZoneInfo KstZone = ResolveKst();
+
     public static string PostRefIDFor(int postID) => $"activity-post:{postID}";
     public static string CommentRefIDFor(int commentID) => $"activity-comment:{commentID}";
 
@@ -61,7 +63,9 @@ public static class ActivityTokenGranter
         // 별도 일일 카운터 엔티티가 없어(MemberDailyRewardCounter 부재) same-day 합계 방식으로 구현한다.
         if (config.DailyTokenCap > 0)
         {
-            var todayStartUtc = DateTime.Now.Date.ToUniversalTime();
+            // 서버 TZ 무관하게 KST 일 경계(자정~익일 자정)를 UTC 범위로 환산해 오늘 지급분을 집계
+            var nowKst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, KstZone);
+            var todayStartUtc = TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(nowKst.Date, DateTimeKind.Unspecified), KstZone);
             var tomorrowStartUtc = todayStartUtc.AddDays(1);
 
             var wallet = await db.Wallet.AsNoTracking().Select(c => new { c.WalletKey, c.MemberID }).FirstOrDefaultAsync(c => c.MemberID == memberID, ct);
@@ -113,4 +117,19 @@ public static class ActivityTokenGranter
 
         return entity?.ActivityToken;
     }
+
+    private static TimeZoneInfo ResolveKst()
+    {
+        if (TimeZoneInfo.TryFindSystemTimeZoneById("Asia/Seoul", out var tz))
+        {
+            return tz;
+        }
+
+        if (TimeZoneInfo.TryFindSystemTimeZoneById("Korea Standard Time", out tz))
+        {
+            return tz;
+        }
+
+        return TimeZoneInfo.CreateCustomTimeZone("KST", TimeSpan.FromHours(9), "KST", "KST");
+    }
 }

+ 14 - 0
Infrastructure/Paper/PaperLeaderboardCache.cs

@@ -103,6 +103,20 @@ internal sealed class PaperLeaderboardCache(IConnectionMultiplexer redis) : IPap
         return new PaperLeaderboardMyRank(rank.Value, (int)score.Value, total);
     }
 
+    /// <summary>전체 (sort, period) 키 삭제 — 다음 조회가 최신 스냅샷으로 재시드(스냅샷 배치 직후 warm-refresh).</summary>
+    public async Task InvalidateAllAsync(CancellationToken ct = default)
+    {
+        var r = redis.GetDatabase();
+
+        foreach (var sort in Enum.GetValues<PaperLeaderboardSort>())
+        {
+            foreach (var period in Enum.GetValues<PaperLeaderboardPeriod>())
+            {
+                await r.KeyDeleteAsync(Key(sort, period));
+            }
+        }
+    }
+
     /// <summary>캐시 미스 → DB 지표 산출 후 ZADD 재시드. TTL 동안 후속 호출은 Redis only.</summary>
     private static async Task EnsureSeededAsync(
         IDatabase r,

+ 5 - 0
Infrastructure/StockData/PaperFillService.cs

@@ -1,4 +1,5 @@
 using Application.Abstractions.Data;
+using Application.Abstractions.Paper;
 using Application.Helpers;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.DependencyInjection;
@@ -17,6 +18,7 @@ namespace Infrastructure.StockData;
 internal sealed class PaperFillService(
     IServiceScopeFactory scopeFactory,
     IAppDbContextFactory dbContextFactory,
+    IPaperLeaderboardCache leaderboardCache,
     ILogger<PaperFillService> logger
 ) : DailyScheduledService(logger)
 {
@@ -69,6 +71,9 @@ internal sealed class PaperFillService(
                 JobName, targetDate, snapResult.Liquidated, snapResult.Snapshots);
         }
 
+        // 스냅샷 반영 후 리더보드 캐시 무효화 → 다음 조회가 최신 순위로 재시드 (warm-refresh)
+        await leaderboardCache.InvalidateAllAsync(ct);
+
         return true;
     }