Handler.cs 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Microsoft.EntityFrameworkCore;
  4. namespace Application.Features.Api.Ranking.GetHallOfFame;
  5. /// <summary>
  6. /// 후원자(Donor) 누적 후원 명예의 전당. Donation.SponsorMemberID GROUP BY 실시간 집계.
  7. /// 비공개 동의(IsRankingPublic=false) 회원은 익명 처리 — 개인정보 보호.
  8. /// </summary>
  9. internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
  10. {
  11. private const string AnonymousName = "비공개 사용자";
  12. public async Task<Response> Handle(Query request, CancellationToken ct)
  13. {
  14. var limit = request.Limit is < 1 or > 500 ? 100 : request.Limit;
  15. var period = HallPeriod.ValidPeriods.Contains(request.Period) ? request.Period : HallPeriod.All;
  16. var (periodStart, periodEnd) = BuildPeriodRange(period);
  17. var donationQuery = db.Donation.AsNoTracking().Where(d => !d.IsTest);
  18. if (periodStart.HasValue)
  19. {
  20. donationQuery = donationQuery.Where(d => d.CreatedAt >= periodStart.Value);
  21. }
  22. if (periodEnd.HasValue)
  23. {
  24. donationQuery = donationQuery.Where(d => d.CreatedAt <= periodEnd.Value);
  25. }
  26. // 후원자별 누적 금액 — 단순 GROUP BY → Sum (EF Core 가 SQL 로 안전하게 변환 가능)
  27. // 주의: GroupBy 안에서 g.Select(...).Distinct().Count() nested aggregate 는 EF Core 가 변환 못 함
  28. // → distinct receiver count 는 별도 쿼리로 분리.
  29. var sums = await donationQuery
  30. .GroupBy(d => d.SponsorMemberID)
  31. .Select(g => new { MemberID = g.Key, Amount = g.Sum(d => (long)d.NetAmount) })
  32. .OrderByDescending(g => g.Amount)
  33. .Take(limit)
  34. .ToListAsync(ct);
  35. if (sums.Count == 0)
  36. {
  37. return new Response([], periodStart, periodEnd);
  38. }
  39. var memberIds = sums.Select(s => s.MemberID).ToList();
  40. // Top N 후원자별 distinct (sponsor, receiver) pair → 메모리에서 sponsor 별로 count
  41. // SQL: SELECT DISTINCT SponsorMemberID, ReceiverMemberID FROM Donation WHERE ... AND SponsorMemberID IN (...)
  42. var pairs = await donationQuery
  43. .Where(d => memberIds.Contains(d.SponsorMemberID))
  44. .Select(d => new { d.SponsorMemberID, d.ReceiverMemberID })
  45. .Distinct()
  46. .ToListAsync(ct);
  47. var peerCounts = pairs.GroupBy(p => p.SponsorMemberID).ToDictionary(g => g.Key, g => g.Count());
  48. var aggregate = sums.Select(s => new AggregateRow(s.MemberID, s.Amount, peerCounts.GetValueOrDefault(s.MemberID, 0))).ToList();
  49. var members = await db.Member.AsNoTracking()
  50. .Where(m => memberIds.Contains(m.ID))
  51. .Select(m => new MemberRow(
  52. m.ID,
  53. m.Name,
  54. m.Thumb,
  55. m.MemberGradeID,
  56. m.MemberGrade != null ? m.MemberGrade.KorName : null,
  57. m.MemberGrade != null ? m.MemberGrade.Image : null,
  58. m.MemberGrade != null ? m.MemberGrade.TextColor : null,
  59. m.MemberApprove != null && m.MemberApprove.IsRankingPublic
  60. ))
  61. .ToListAsync(ct);
  62. var memberDict = members.ToDictionary(m => m.ID, m => m);
  63. var top100 = aggregate.Select((row, i) => {
  64. var m = memberDict.GetValueOrDefault(row.MemberID);
  65. // 비공개 동의 회원 익명 처리 (개인정보 보호)
  66. var anonymize = m is not null && !m.IsRankingPublic;
  67. return new HallEntry(
  68. i + 1,
  69. row.MemberID,
  70. anonymize ? AnonymousName : (m?.Name ?? ""),
  71. anonymize ? null : m?.Thumb,
  72. null, // donor는 channel 컨텍스트 없음
  73. row.Amount,
  74. row.PeerCount,
  75. anonymize ? null : m?.MemberGradeID,
  76. anonymize ? null : m?.GradeKorName,
  77. anonymize ? null : m?.GradeImage,
  78. anonymize ? null : m?.GradeTextColor,
  79. anonymize
  80. );
  81. }).ToList();
  82. return new Response(top100, periodStart, periodEnd);
  83. }
  84. /// <summary>
  85. /// 기간 키워드 → UTC 롤링 윈도우 (start, end). All 은 (null, null).
  86. /// </summary>
  87. private static (DateTime? start, DateTime? end) BuildPeriodRange(string period)
  88. {
  89. var now = DateTime.UtcNow;
  90. return period switch
  91. {
  92. HallPeriod.Today => (now.Date, now),
  93. HallPeriod.Week => (now.AddDays(-7), now),
  94. HallPeriod.Month => (now.AddMonths(-1), now),
  95. HallPeriod.Quarter => (now.AddMonths(-3), now),
  96. HallPeriod.Half => (now.AddMonths(-6), now),
  97. _ => (null, null)
  98. };
  99. }
  100. private sealed record AggregateRow(int MemberID, long Amount, int PeerCount);
  101. private sealed record MemberRow(
  102. int ID,
  103. string? Name,
  104. string? Thumb,
  105. int? MemberGradeID,
  106. string? GradeKorName,
  107. string? GradeImage,
  108. string? GradeTextColor,
  109. bool IsRankingPublic
  110. );
  111. }