using Application.Abstractions.Data; using Domain.Entities.Donations; using Domain.Entities.Donations.ValueObject; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Infrastructure.Ranking; /// /// 매일 자정 00:30(KST) 1회 실행 — 랭킹 스냅샷 집계. /// - YouTubeDailyAggregator 완료 후 실행 (00:00 + 30분 버퍼) /// - 3가지 랭킹 × 6가지 기간 × (전체 + 카테고리) = N개의 스냅샷 생성 /// - 기존 스냅샷은 삭제 후 재생성 (upsert 대신 단순 truncate+insert) /// /// 종합 점수 가중치 (Comprehensive): /// 머니 × 0.55 + 후원자수(×10000 환산) × 0.15 + 방송시간(초 / 3600) × 0.15 /// + 조회수 × 0.1 + 좋아요 × 0.05 /// internal sealed class RankingDailyAggregatorService( IServiceScopeFactory scopeFactory, ILogger logger ) : BackgroundService { private static readonly TimeSpan InitialDelay = TimeSpan.FromMinutes(3); private static readonly TimeZoneInfo Kst = TimeZoneInfo.FindSystemTimeZoneById("Korea Standard Time"); private const int OffsetMinutesAfterMidnight = 30; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await Task.Delay(InitialDelay, stoppingToken); logger.LogInformation("[RankingAggregator] 서비스 시작 — 매일 KST 00:{Offset} 실행", OffsetMinutesAfterMidnight.ToString("D2")); while (!stoppingToken.IsCancellationRequested) { var delay = CalculateDelayUntilNextRun(); logger.LogInformation("[RankingAggregator] 다음 실행까지 대기: {Hours}시간 {Minutes}분", delay.Hours, delay.Minutes); try { await Task.Delay(delay, stoppingToken); } catch (TaskCanceledException) { break; } try { await AggregateAllAsync(stoppingToken); } catch (Exception ex) { logger.LogError(ex, "[RankingAggregator] 집계 실행 중 오류"); } } } private static TimeSpan CalculateDelayUntilNextRun() { var nowKst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Kst); var nextRunKst = nowKst.Date.AddDays(1).AddMinutes(OffsetMinutesAfterMidnight); var delay = nextRunKst - nowKst; if (delay <= TimeSpan.Zero) { delay = TimeSpan.FromMinutes(1); } return delay; } private async Task AggregateAllAsync(CancellationToken ct) { logger.LogInformation("[RankingAggregator] 스냅샷 집계 시작"); var periods = Enum.GetValues(); var types = Enum.GetValues(); foreach (var period in periods) { foreach (var type in types) { try { await AggregateOneAsync(type, period, null, ct); } catch (Exception ex) { logger.LogError(ex, "[RankingAggregator] {Type}/{Period} 집계 실패", type, period); } } } logger.LogInformation("[RankingAggregator] 스냅샷 집계 완료"); } private async Task AggregateOneAsync(RankingType type, RankingPeriod period, string? category, CancellationToken ct) { using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var (periodStart, periodEnd) = ResolvePeriodRange(period); // 기존 스냅샷 삭제 (동일 type/period/category) var existing = await db.RankingSnapshot .Where(r => r.Type == type && r.Period == period && r.Category == category) .ToListAsync(ct); if (existing.Count > 0) { db.RankingSnapshot.RemoveRange(existing); await db.SaveChangesAsync(ct); } // 전일 스냅샷 (PreviousRank 계산용) var previousRanks = existing .GroupBy(r => r.MemberID) .ToDictionary(g => g.Key, g => g.First().Rank); List rows = type switch { RankingType.Comprehensive => await AggregateComprehensiveAsync(db, periodStart, periodEnd, ct), RankingType.Creator => await AggregateCreatorAsync(db, periodStart, periodEnd, ct), RankingType.Donor => await AggregateDonorAsync(db, periodStart, periodEnd, ct), _ => [] }; if (rows.Count == 0) { return; } var ranked = rows .OrderByDescending(r => r.Score) .ThenByDescending(r => r.MoneyAmount) .ToList(); var now = DateTime.UtcNow; var snapshots = new List(ranked.Count); for (var i = 0; i < ranked.Count; i++) { var row = ranked[i]; var rank = i + 1; int? previousRank = previousRanks.GetValueOrDefault(row.MemberID) is var p && p > 0 ? p : null; snapshots.Add(RankingSnapshot.Create( type, period, periodStart, periodEnd, category, row.MemberID, rank, row.Score, row.MoneyAmount, row.DonorCount, row.BroadcastSec, row.ViewCount, row.LikeCount, previousRank )); } await db.RankingSnapshot.AddRangeAsync(snapshots, ct); await db.SaveChangesAsync(ct); logger.LogInformation("[RankingAggregator] {Type}/{Period} — {Count}개 스냅샷 생성", type, period, snapshots.Count); } private static (DateTime Start, DateTime End) ResolvePeriodRange(RankingPeriod period) { var nowKst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Kst); var today = nowKst.Date; return period switch { RankingPeriod.Today => (ToUtc(today), ToUtc(today.AddDays(1))), RankingPeriod.Yesterday => (ToUtc(today.AddDays(-1)), ToUtc(today)), RankingPeriod.Week => (ToUtc(today.AddDays(-(int)today.DayOfWeek)), ToUtc(today.AddDays(1))), RankingPeriod.Month => (ToUtc(new DateTime(today.Year, today.Month, 1)), ToUtc(today.AddDays(1))), RankingPeriod.LastMonth => ( ToUtc(new DateTime(today.Year, today.Month, 1).AddMonths(-1)), ToUtc(new DateTime(today.Year, today.Month, 1)) ), RankingPeriod.All => (DateTime.MinValue.ToUniversalTime(), ToUtc(today.AddDays(1))), _ => (ToUtc(new DateTime(today.Year, today.Month, 1)), ToUtc(today.AddDays(1))) }; } private static DateTime ToUtc(DateTime kstDate) { var kstUnspecified = DateTime.SpecifyKind(kstDate, DateTimeKind.Unspecified); return TimeZoneInfo.ConvertTimeToUtc(kstUnspecified, Kst); } private static async Task> AggregateCreatorAsync(IAppDbContext db, DateTime start, DateTime end, CancellationToken ct) { var grouped = await db.Donation.AsNoTracking() .Where(d => d.CreatedAt >= start && d.CreatedAt < end && !d.IsTest) .GroupBy(d => d.ReceiverMemberID) .Select(g => new { MemberID = g.Key, MoneyAmount = g.Sum(d => (long)d.NetAmount), DonorCount = g.Select(d => d.SponsorMemberID).Distinct().Count() }) .ToListAsync(ct); return grouped.Select(g => new AggregatedRow { MemberID = g.MemberID, MoneyAmount = g.MoneyAmount, DonorCount = g.DonorCount, BroadcastSec = 0, ViewCount = 0, LikeCount = 0, Score = g.MoneyAmount }).ToList(); } private static async Task> AggregateDonorAsync(IAppDbContext db, DateTime start, DateTime end, CancellationToken ct) { var grouped = await db.Donation.AsNoTracking() .Where(d => d.CreatedAt >= start && d.CreatedAt < end && !d.IsTest) .GroupBy(d => d.SponsorMemberID) .Select(g => new { MemberID = g.Key, MoneyAmount = g.Sum(d => (long)d.Amount), DonorCount = g.Count() }) .ToListAsync(ct); return grouped.Select(g => new AggregatedRow { MemberID = g.MemberID, MoneyAmount = g.MoneyAmount, DonorCount = g.DonorCount, BroadcastSec = 0, ViewCount = 0, LikeCount = 0, Score = g.MoneyAmount }).ToList(); } private static async Task> AggregateComprehensiveAsync(IAppDbContext db, DateTime start, DateTime end, CancellationToken ct) { var creatorRows = await AggregateCreatorAsync(db, start, end, ct); var creatorMap = creatorRows.ToDictionary(r => r.MemberID, r => r); // Member ID 목록 수집 (후원 받은 회원 + 방송 활동 회원) var broadcastStats = await db.ChannelBroadcastStats.AsNoTracking() .Join(db.Channel.AsNoTracking(), s => s.ChannelID, c => c.ID, (s, c) => new { c.MemberID, s.TotalDurationSec, s.CumulativeViews, s.TotalLikes }) .ToListAsync(ct); foreach (var stat in broadcastStats) { if (creatorMap.TryGetValue(stat.MemberID, out var row)) { row.BroadcastSec = stat.TotalDurationSec; row.ViewCount = stat.CumulativeViews; row.LikeCount = stat.TotalLikes; } else { creatorMap[stat.MemberID] = new AggregatedRow { MemberID = stat.MemberID, MoneyAmount = 0, DonorCount = 0, BroadcastSec = stat.TotalDurationSec, ViewCount = stat.CumulativeViews, LikeCount = stat.TotalLikes }; } } foreach (var row in creatorMap.Values) { row.Score = CalculateComprehensiveScore(row); } return creatorMap.Values.ToList(); } private static long CalculateComprehensiveScore(AggregatedRow row) { // 머니 × 0.55 + 후원자수 × 10000 × 0.15 + 방송시간/3600(시) × 10000 × 0.15 + 조회수 × 0.1 + 좋아요 × 0.05 var moneyScore = row.MoneyAmount * 0.55; var donorScore = row.DonorCount * 10000.0 * 0.15; var broadcastScore = (row.BroadcastSec / 3600.0) * 10000.0 * 0.15; var viewScore = row.ViewCount * 0.1; var likeScore = row.LikeCount * 0.05; return (long)(moneyScore + donorScore + broadcastScore + viewScore + likeScore); } private sealed class AggregatedRow { public int MemberID { get; set; } public long Score { get; set; } public long MoneyAmount { get; set; } public int DonorCount { get; set; } public long BroadcastSec { get; set; } public long ViewCount { get; set; } public long LikeCount { get; set; } } }