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