using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Microsoft.EntityFrameworkCore; namespace Application.Features.Api.Ranking.GetHallOfFame; /// /// 후원자(Donor) 누적 후원 명예의 전당. Donation.SponsorMemberID GROUP BY 실시간 집계. /// 비공개 동의(IsRankingPublic=false) 회원은 익명 처리 — 개인정보 보호. /// internal sealed class Handler(IAppDbContext db) : IQueryHandler { private const string AnonymousName = "비공개 사용자"; public async Task Handle(Query request, CancellationToken ct) { var limit = request.Limit is < 1 or > 500 ? 100 : request.Limit; var period = HallPeriod.ValidPeriods.Contains(request.Period) ? request.Period : HallPeriod.All; var (periodStart, periodEnd) = BuildPeriodRange(period); var donationQuery = db.Donation.AsNoTracking().Where(d => !d.IsTest); if (periodStart.HasValue) { donationQuery = donationQuery.Where(d => d.CreatedAt >= periodStart.Value); } if (periodEnd.HasValue) { donationQuery = donationQuery.Where(d => d.CreatedAt <= periodEnd.Value); } // 후원자별 누적 금액 — 단순 GROUP BY → Sum (EF Core 가 SQL 로 안전하게 변환 가능) // 주의: GroupBy 안에서 g.Select(...).Distinct().Count() nested aggregate 는 EF Core 가 변환 못 함 // → distinct receiver count 는 별도 쿼리로 분리. var sums = await donationQuery .GroupBy(d => d.SponsorMemberID) .Select(g => new { MemberID = g.Key, Amount = g.Sum(d => (long)d.NetAmount) }) .OrderByDescending(g => g.Amount) .Take(limit) .ToListAsync(ct); if (sums.Count == 0) { return new Response([], periodStart, periodEnd); } var memberIds = sums.Select(s => s.MemberID).ToList(); // Top N 후원자별 distinct (sponsor, receiver) pair → 메모리에서 sponsor 별로 count // SQL: SELECT DISTINCT SponsorMemberID, ReceiverMemberID FROM Donation WHERE ... AND SponsorMemberID IN (...) var pairs = await donationQuery .Where(d => memberIds.Contains(d.SponsorMemberID)) .Select(d => new { d.SponsorMemberID, d.ReceiverMemberID }) .Distinct() .ToListAsync(ct); var peerCounts = pairs.GroupBy(p => p.SponsorMemberID).ToDictionary(g => g.Key, g => g.Count()); var aggregate = sums.Select(s => new AggregateRow(s.MemberID, s.Amount, peerCounts.GetValueOrDefault(s.MemberID, 0))).ToList(); var members = await db.Member.AsNoTracking() .Where(m => memberIds.Contains(m.ID)) .Select(m => new MemberRow( m.ID, m.Name, m.Thumb, m.MemberGradeID, m.MemberGrade != null ? m.MemberGrade.KorName : null, m.MemberGrade != null ? m.MemberGrade.Image : null, m.MemberGrade != null ? m.MemberGrade.TextColor : null, m.MemberApprove != null && m.MemberApprove.IsRankingPublic )) .ToListAsync(ct); var memberDict = members.ToDictionary(m => m.ID, m => m); var top100 = aggregate.Select((row, i) => { var m = memberDict.GetValueOrDefault(row.MemberID); // 비공개 동의 회원 익명 처리 (개인정보 보호) var anonymize = m is not null && !m.IsRankingPublic; return new HallEntry( i + 1, row.MemberID, anonymize ? AnonymousName : (m?.Name ?? ""), anonymize ? null : m?.Thumb, null, // donor는 channel 컨텍스트 없음 row.Amount, row.PeerCount, anonymize ? null : m?.MemberGradeID, anonymize ? null : m?.GradeKorName, anonymize ? null : m?.GradeImage, anonymize ? null : m?.GradeTextColor, anonymize ); }).ToList(); return new Response(top100, periodStart, periodEnd); } /// /// 기간 키워드 → UTC 롤링 윈도우 (start, end). All 은 (null, null). /// private static (DateTime? start, DateTime? end) BuildPeriodRange(string period) { var now = DateTime.UtcNow; return period switch { HallPeriod.Today => (now.Date, now), HallPeriod.Week => (now.AddDays(-7), now), HallPeriod.Month => (now.AddMonths(-1), now), HallPeriod.Quarter => (now.AddMonths(-3), now), HallPeriod.Half => (now.AddMonths(-6), now), _ => (null, null) }; } private sealed record AggregateRow(int MemberID, long Amount, int PeerCount); private sealed record MemberRow( int ID, string? Name, string? Thumb, int? MemberGradeID, string? GradeKorName, string? GradeImage, string? GradeTextColor, bool IsRankingPublic ); }