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