| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315 |
- 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;
- /// <summary>
- /// 매일 자정 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
- /// </summary>
- internal sealed class RankingDailyAggregatorService(
- IServiceScopeFactory scopeFactory,
- ILogger<RankingDailyAggregatorService> 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<RankingPeriod>();
- var types = Enum.GetValues<RankingType>();
- 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<IAppDbContext>();
- 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<AggregatedRow> 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<RankingSnapshot>(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<List<AggregatedRow>> 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<List<AggregatedRow>> 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<List<AggregatedRow>> 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; }
- }
- }
|