Handler.cs 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. using Application.Abstractions.Cache;
  2. using Application.Abstractions.Channels;
  3. using Application.Abstractions.Data;
  4. using Application.Abstractions.Messaging;
  5. using Application.Helpers;
  6. using SharedKernel.Results;
  7. using Domain.Entities.Common.ValueObject;
  8. using Domain.Entities.Donations;
  9. using Domain.Entities.Donations.ValueObject;
  10. using Domain.Entities.Members.Logs;
  11. using Microsoft.EntityFrameworkCore;
  12. namespace Application.Features.Api.Donation.Send;
  13. internal sealed class Handler(IAppDbContext db, ICacheService cache) : ICommandHandler<Command, Result<Response>>
  14. {
  15. public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
  16. {
  17. // 0. ChannelSID/@handle → ChannelID 변환 (ChannelID 미지정 시)
  18. if (request.ChannelID == 0 && !string.IsNullOrEmpty(request.ChannelSID))
  19. {
  20. var resolvedIdentifier = await ChannelIdentifierResolver.ResolveAsync(db, request.ChannelSID, ct);
  21. if (resolvedIdentifier.ChannelSID is null)
  22. {
  23. return Result.Failure<Response>(Error.NotFound("Channel.NotFound", "채널을 찾을 수 없습니다."));
  24. }
  25. var resolved = await db.Channel.AsNoTracking()
  26. .Where(c => c.SID == resolvedIdentifier.ChannelSID)
  27. .Select(c => c.ID)
  28. .FirstOrDefaultAsync(ct);
  29. if (resolved == 0)
  30. {
  31. return Result.Failure<Response>(Error.NotFound("Channel.NotFound", "채널을 찾을 수 없습니다."));
  32. }
  33. request = request with { ChannelID = resolved };
  34. }
  35. // 1. 채널 조회 + 수수료율
  36. var channel = await db.Channel
  37. .AsNoTracking()
  38. .Where(c => c.ID == request.ChannelID && c.IsActive)
  39. .Select(c => new { c.ID, c.MemberID, c.PlatformFeeRate })
  40. .FirstOrDefaultAsync(ct);
  41. if (channel is null)
  42. {
  43. return Result.Failure<Response>(Error.NotFound("Channel.NotFound", "채널을 찾을 수 없습니다."));
  44. }
  45. // 2. 자기 자신에게 후원 불가
  46. if (request.SponsorMemberID == channel.MemberID && !request.IsTest)
  47. {
  48. return Result.Failure<Response>(Error.Problem("Donation.SelfDonationNotAllowed", "본인 채널에 후원할 수 없습니다."));
  49. }
  50. // 3. 후원 수락 상태 확인
  51. var meta = await db.DonationMeta
  52. .AsNoTracking()
  53. .FirstOrDefaultAsync(m => m.ChannelID == request.ChannelID, ct);
  54. if (meta is not null && !meta.IsAccepting)
  55. {
  56. return Result.Failure<Response>(Error.Problem("Donation.NotAccepting", "현재 후원을 받지 않는 채널입니다."));
  57. }
  58. var minAmount = meta?.MinAmount ?? DonationConstants.MinAmount;
  59. if (request.Amount < minAmount)
  60. {
  61. return Result.Failure<Response>(Error.Problem("Donation.BelowMinAmount", $"최소 후원 금액은 {minAmount}원입니다."));
  62. }
  63. if (request.Amount > DonationConstants.MaxAmount)
  64. {
  65. return Result.Failure<Response>(Error.Problem("Donation.AboveMaxAmount", $"최대 후원 금액은 {DonationConstants.MaxAmount:N0}원입니다."));
  66. }
  67. // 4. 크루 세션 검증 (크루 후원인 경우)
  68. if (request.CrewSessionID.HasValue)
  69. {
  70. var session = await db.CrewSession
  71. .AsNoTracking()
  72. .FirstOrDefaultAsync(s => s.ID == request.CrewSessionID.Value && s.Status == CrewSessionStatus.Active, ct);
  73. if (session is null)
  74. {
  75. return Result.Failure<Response>(Error.Problem("Crew.SessionNotActive", "활성화된 크루 후원 방송이 아닙니다."));
  76. }
  77. }
  78. // 5. 후원자 지갑 조회 + 잔액 차감
  79. var sponsorWallet = await db.Wallet
  80. .Include(w => w.Balances)
  81. .FirstOrDefaultAsync(w => w.MemberID == request.SponsorMemberID, ct);
  82. if (sponsorWallet is null)
  83. {
  84. return Result.Failure<Response>(Error.NotFound("Wallet.NotFound", "지갑을 찾을 수 없습니다."));
  85. }
  86. var spendAmount = Money.KRW(request.Amount);
  87. var totalAvailable = sponsorWallet.GetTotalAvailable();
  88. if (totalAvailable.Value < spendAmount.Value)
  89. {
  90. return Result.Failure<Response>(Error.Problem("Wallet.InsufficientBalance", "잔액이 부족합니다."));
  91. }
  92. // 6. Donation 생성 (수수료 스냅샷)
  93. var donation = Domain.Entities.Donations.Donation.Create(
  94. sponsorMemberID: request.SponsorMemberID,
  95. receiverMemberID: channel.MemberID,
  96. channelID: channel.ID,
  97. amount: request.Amount,
  98. feeRate: channel.PlatformFeeRate,
  99. message: request.Message,
  100. sendName: request.SendName,
  101. crewSessionID: request.CrewSessionID,
  102. crewMemberID: request.CrewMemberID,
  103. isTest: request.IsTest
  104. );
  105. db.Donation.Add(donation);
  106. // 7. 후원자 지갑 차감
  107. if (!request.IsTest)
  108. {
  109. sponsorWallet.Spend(spendAmount, "DONATION_OUT", donation.ID.ToString());
  110. }
  111. // 8. 수신자 지갑에 입금 (NetAmount)
  112. if (!request.IsTest)
  113. {
  114. var receiverWallet = await db.Wallet
  115. .Include(w => w.Balances)
  116. .FirstOrDefaultAsync(w => w.MemberID == channel.MemberID, ct);
  117. if (receiverWallet is not null)
  118. {
  119. receiverWallet.CreditDonationIn(Money.KRW(donation.NetAmount), "DONATION_IN", donation.ID.ToString());
  120. }
  121. }
  122. // SaveChanges로 Donation ID 확보
  123. await db.SaveChangesAsync(ct);
  124. // 9. DonationAlert 생성
  125. var alert = Domain.Entities.Donations.DonationAlert.Create(
  126. donationID: donation.ID,
  127. sponsorMemberID: request.SponsorMemberID,
  128. receiverMemberID: channel.MemberID
  129. );
  130. db.DonationAlert.Add(alert);
  131. // 9-1. 첫 Alert Attempt 생성 (재전송 로직 일관성 확보)
  132. await db.SaveChangesAsync(ct);
  133. var firstAttempt = DonationAlertAttempt.Create(donation.ID, alert.ID);
  134. db.DonationAlertAttempt.Add(firstAttempt);
  135. // 10. 크루 후원인 경우 집계 갱신
  136. if (request.CrewSessionID.HasValue && request.CrewMemberID.HasValue)
  137. {
  138. var summary = await db.CrewDonationSummary
  139. .FirstOrDefaultAsync(s => s.CrewSessionID == request.CrewSessionID.Value
  140. && s.CrewMemberID == request.CrewMemberID.Value, ct);
  141. if (summary is null)
  142. {
  143. summary = CrewDonationSummary.Create(request.CrewSessionID.Value, request.CrewMemberID.Value);
  144. db.CrewDonationSummary.Add(summary);
  145. }
  146. summary.AddDonation(request.Amount);
  147. // 세션 전체 합계 갱신
  148. var session = await db.CrewSession.FindAsync([request.CrewSessionID.Value], ct);
  149. session?.AddDonation(request.Amount);
  150. // 기여도 재계산
  151. if (session is not null && session.TotalAmount > 0)
  152. {
  153. var allSummaries = await db.CrewDonationSummary
  154. .Where(s => s.CrewSessionID == session.ID)
  155. .OrderByDescending(s => s.TotalAmount)
  156. .ToListAsync(ct);
  157. var rank = 1;
  158. foreach (var s in allSummaries)
  159. {
  160. var rate = (decimal)s.TotalAmount / session.TotalAmount * 100;
  161. s.UpdateContribution(rate, rank++);
  162. }
  163. }
  164. }
  165. // 11. 후원자×채널 누적 집계 (채널 칭호 획득 판정용)
  166. if (!request.IsTest)
  167. {
  168. var stats = await db.DonorChannelStats
  169. .FirstOrDefaultAsync(s => s.DonorMemberID == request.SponsorMemberID && s.ChannelID == channel.ID, ct);
  170. if (stats is null)
  171. {
  172. stats = DonorChannelStats.Create(request.SponsorMemberID, channel.ID);
  173. await db.DonorChannelStats.AddAsync(stats, ct);
  174. }
  175. stats.AddDonation(donation.Amount, donation.CreatedAt);
  176. }
  177. await db.SaveChangesAsync(ct);
  178. // 12. 후원 XP 적립 (방송 세션 활성 시 리더보드 집계 대상)
  179. if (!request.IsTest)
  180. {
  181. var cfg = await ChatExpConfigLoader.GetAsync(cache, db, ct);
  182. if (cfg.DonationXpPerAmount > 0)
  183. {
  184. var xpGrant = (int)(request.Amount / cfg.DonationXpPerAmount);
  185. if (xpGrant > 0)
  186. {
  187. var session = await db.BroadcastSession.AsNoTracking()
  188. .Where(s => s.ChannelID == channel.ID && !s.IsFinalized)
  189. .OrderByDescending(s => s.CreatedAt)
  190. .Select(s => new { s.ID })
  191. .FirstOrDefaultAsync(ct);
  192. var stats = await db.MemberStats.FirstOrDefaultAsync(s => s.MemberID == request.SponsorMemberID, ct);
  193. if (stats is not null)
  194. {
  195. stats.Exp += xpGrant;
  196. var expLog = session is not null
  197. ? MemberExpLog.CreateForBroadcast(request.SponsorMemberID, "후원(라이브)", xpGrant, stats.Exp, session.ID, channel.ID)
  198. : MemberExpLog.Create(request.SponsorMemberID, "후원", xpGrant, stats.Exp);
  199. db.MemberExpLog.Add(expLog);
  200. await db.SaveChangesAsync(ct);
  201. }
  202. }
  203. }
  204. }
  205. // SignalR Push는 Endpoint 레이어에서 처리 (ReceiveAlert)
  206. return Result.Success(new Response(donation.ID, alert.ID, alert.CorrelationID, donation.Amount, donation.FeeAmount, donation.NetAmount));
  207. }
  208. }