Handler.cs 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Domain.Entities.Common.ValueObject;
  4. using Domain.Entities.Donations;
  5. using Domain.Entities.Donations.ValueObject;
  6. using Microsoft.EntityFrameworkCore;
  7. namespace Application.Features.Api.Donation.Send;
  8. internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Response>
  9. {
  10. public async Task<Response> Handle(Command request, CancellationToken ct)
  11. {
  12. // 0. ChannelSID → ChannelID 변환 (ChannelSID가 제공된 경우)
  13. if (request.ChannelID == 0 && !string.IsNullOrEmpty(request.ChannelSID))
  14. {
  15. var resolved = await db.Channel.AsNoTracking()
  16. .Where(c => c.SID == request.ChannelSID)
  17. .Select(c => c.ID)
  18. .FirstOrDefaultAsync(ct);
  19. if (resolved == 0) throw new KeyNotFoundException("채널을 찾을 수 없습니다.");
  20. request = request with { ChannelID = resolved };
  21. }
  22. // 1. 채널 조회 + 수수료율
  23. var channel = await db.Channel
  24. .AsNoTracking()
  25. .Where(c => c.ID == request.ChannelID && c.IsActive)
  26. .Select(c => new { c.ID, c.MemberID, c.PlatformFeeRate })
  27. .FirstOrDefaultAsync(ct);
  28. if (channel is null)
  29. {
  30. throw new KeyNotFoundException("채널을 찾을 수 없습니다.");
  31. }
  32. // 2. 자기 자신에게 후원 불가
  33. if (request.SponsorMemberID == channel.MemberID && !request.IsTest)
  34. {
  35. throw new InvalidOperationException("본인 채널에 후원할 수 없습니다.");
  36. }
  37. // 3. 후원 수락 상태 확인
  38. var meta = await db.DonationMeta
  39. .AsNoTracking()
  40. .FirstOrDefaultAsync(m => m.ChannelID == request.ChannelID, ct);
  41. if (meta is not null && !meta.IsAccepting)
  42. {
  43. throw new InvalidOperationException("현재 후원을 받지 않는 채널입니다.");
  44. }
  45. var minAmount = meta?.MinAmount ?? DonationConstants.MinAmount;
  46. if (request.Amount < minAmount)
  47. {
  48. throw new InvalidOperationException($"최소 후원 금액은 {minAmount}원입니다.");
  49. }
  50. if (request.Amount > DonationConstants.MaxAmount)
  51. {
  52. throw new InvalidOperationException($"최대 후원 금액은 {DonationConstants.MaxAmount:N0}원입니다.");
  53. }
  54. // 4. 크루 세션 검증 (크루 후원인 경우)
  55. if (request.CrewSessionID.HasValue)
  56. {
  57. var session = await db.CrewSession
  58. .AsNoTracking()
  59. .FirstOrDefaultAsync(s => s.ID == request.CrewSessionID.Value && s.Status == CrewSessionStatus.Active, ct);
  60. if (session is null)
  61. {
  62. throw new InvalidOperationException("활성화된 크루 후원 방송이 아닙니다.");
  63. }
  64. }
  65. // 5. 후원자 지갑 조회 + 잔액 차감
  66. var sponsorWallet = await db.Wallet
  67. .Include(w => w.Balances)
  68. .FirstOrDefaultAsync(w => w.MemberID == request.SponsorMemberID, ct);
  69. if (sponsorWallet is null)
  70. {
  71. throw new KeyNotFoundException("지갑을 찾을 수 없습니다.");
  72. }
  73. var spendAmount = Money.KRW(request.Amount);
  74. var totalAvailable = sponsorWallet.GetTotalAvailable();
  75. if (totalAvailable.Value < spendAmount.Value)
  76. {
  77. throw new InvalidOperationException("잔액이 부족합니다.");
  78. }
  79. // 6. Donation 생성 (수수료 스냅샷)
  80. var donation = Domain.Entities.Donations.Donation.Create(
  81. sponsorMemberID: request.SponsorMemberID,
  82. receiverMemberID: channel.MemberID,
  83. channelID: channel.ID,
  84. amount: request.Amount,
  85. feeRate: channel.PlatformFeeRate,
  86. message: request.Message,
  87. sendName: request.SendName,
  88. crewSessionID: request.CrewSessionID,
  89. crewMemberID: request.CrewMemberID,
  90. isTest: request.IsTest
  91. );
  92. db.Donation.Add(donation);
  93. // 7. 후원자 지갑 차감
  94. if (!request.IsTest)
  95. {
  96. sponsorWallet.Spend(spendAmount, "DONATION_OUT", donation.ID.ToString());
  97. }
  98. // 8. 수신자 지갑에 입금 (NetAmount)
  99. if (!request.IsTest)
  100. {
  101. var receiverWallet = await db.Wallet
  102. .Include(w => w.Balances)
  103. .FirstOrDefaultAsync(w => w.MemberID == channel.MemberID, ct);
  104. if (receiverWallet is not null)
  105. {
  106. receiverWallet.CreditDonationIn(Money.KRW(donation.NetAmount), "DONATION_IN", donation.ID.ToString());
  107. }
  108. }
  109. // SaveChanges로 Donation ID 확보
  110. await db.SaveChangesAsync(ct);
  111. // 9. DonationAlert 생성
  112. var alert = Domain.Entities.Donations.DonationAlert.Create(
  113. donationID: donation.ID,
  114. sponsorMemberID: request.SponsorMemberID,
  115. receiverMemberID: channel.MemberID
  116. );
  117. db.DonationAlert.Add(alert);
  118. // 10. 크루 후원인 경우 집계 갱신
  119. if (request.CrewSessionID.HasValue && request.CrewMemberID.HasValue)
  120. {
  121. var summary = await db.CrewDonationSummary
  122. .FirstOrDefaultAsync(s => s.CrewSessionID == request.CrewSessionID.Value
  123. && s.CrewMemberID == request.CrewMemberID.Value, ct);
  124. if (summary is null)
  125. {
  126. summary = CrewDonationSummary.Create(request.CrewSessionID.Value, request.CrewMemberID.Value);
  127. db.CrewDonationSummary.Add(summary);
  128. }
  129. summary.AddDonation(request.Amount);
  130. // 세션 전체 합계 갱신
  131. var session = await db.CrewSession.FindAsync([request.CrewSessionID.Value], ct);
  132. session?.AddDonation(request.Amount);
  133. // 기여도 재계산
  134. if (session is not null && session.TotalAmount > 0)
  135. {
  136. var allSummaries = await db.CrewDonationSummary
  137. .Where(s => s.CrewSessionID == session.ID)
  138. .OrderByDescending(s => s.TotalAmount)
  139. .ToListAsync(ct);
  140. var rank = 1;
  141. foreach (var s in allSummaries)
  142. {
  143. var rate = (decimal)s.TotalAmount / session.TotalAmount * 100;
  144. s.UpdateContribution(rate, rank++);
  145. }
  146. }
  147. }
  148. await db.SaveChangesAsync(ct);
  149. // SignalR Push는 Endpoint 레이어 또는 별도 서비스에서 처리
  150. return new Response(donation.ID, alert.CorrelationID, donation.Amount, donation.FeeAmount, donation.NetAmount);
  151. }
  152. }