| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134 |
- using Application.Abstractions.Data;
- using Application.Abstractions.Messaging;
- using Microsoft.EntityFrameworkCore;
- namespace Application.Features.Api.Ranking.GetHallOfFame;
- /// <summary>
- /// 후원자(Donor) 누적 후원 명예의 전당. Donation.SponsorMemberID GROUP BY 실시간 집계.
- /// 비공개 동의(IsRankingPublic=false) 회원은 익명 처리 — 개인정보 보호.
- /// </summary>
- internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
- {
- private const string AnonymousName = "비공개 사용자";
- public async Task<Response> 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);
- }
- /// <summary>
- /// 기간 키워드 → UTC 롤링 윈도우 (start, end). All 은 (null, null).
- /// </summary>
- 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
- );
- }
|