Handler.cs 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using SharedKernel.Results;
  4. using Domain.Entities.Donations;
  5. using Domain.Entities.Donations.ValueObject;
  6. using Microsoft.EntityFrameworkCore;
  7. namespace Application.Features.Api.DonationRemote.TestDonate;
  8. internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<Response>>
  9. {
  10. public async Task<Result<Response>> Handle(Command r, CancellationToken ct)
  11. {
  12. if (r.Amount <= 0)
  13. {
  14. return Result.Failure<Response>(Error.Problem("Donation.InvalidAmount", "금액은 0보다 커야 합니다."));
  15. }
  16. if (r.Amount < DonationConstants.MinAmount)
  17. {
  18. return Result.Failure<Response>(Error.Problem("Donation.BelowMinAmount", $"최소 후원 금액은 {DonationConstants.MinAmount}원입니다."));
  19. }
  20. if (r.Amount > DonationConstants.MaxAmount)
  21. {
  22. return Result.Failure<Response>(Error.Problem("Donation.AboveMaxAmount", $"최대 후원 금액은 {DonationConstants.MaxAmount:N0}원입니다."));
  23. }
  24. if (string.IsNullOrWhiteSpace(r.SendName))
  25. {
  26. return Result.Failure<Response>(Error.Problem("Donation.SendNameRequired", "별명이 필요합니다."));
  27. }
  28. var channel = await db.Channel.AsNoTracking()
  29. .Where(c => c.WidgetToken == r.WidgetToken && c.IsActive)
  30. .Select(c => new { c.ID, c.MemberID, c.PlatformFeeRate, c.Name })
  31. .FirstOrDefaultAsync(ct);
  32. if (channel is null)
  33. {
  34. return Result.Failure<Response>(Error.NotFound("Channel.NotFound", "채널을 찾을 수 없습니다."));
  35. }
  36. if (channel.MemberID != r.RequesterMemberID)
  37. {
  38. return Result.Failure<Response>(Error.Forbidden("Remote.Forbidden", "권한이 없습니다."));
  39. }
  40. // 크루 후원 검증 + 활성 세션 조회 (영구 반영 시에만 세션 필요)
  41. int? crewSessionID = null;
  42. string? crewMemberNickname = null;
  43. if (r.CrewMemberID.HasValue)
  44. {
  45. var crewMember = await db.CrewMember.AsNoTracking()
  46. .Where(m => m.ID == r.CrewMemberID.Value && m.IsActive)
  47. .Select(m => new { m.ID, m.CrewID, m.Nickname, CrewChannelID = m.Crew!.ChannelID })
  48. .FirstOrDefaultAsync(ct);
  49. if (crewMember is null)
  50. {
  51. return Result.Failure<Response>(Error.NotFound("CrewMember.NotFound", "크루원을 찾을 수 없습니다."));
  52. }
  53. if (crewMember.CrewChannelID != channel.ID)
  54. {
  55. return Result.Failure<Response>(Error.Forbidden("CrewMember.Forbidden", "다른 채널의 크루원입니다."));
  56. }
  57. crewMemberNickname = crewMember.Nickname;
  58. if (!r.IsTest)
  59. {
  60. crewSessionID = await db.CrewSession.AsNoTracking()
  61. .Where(s => s.CrewID == crewMember.CrewID && s.Status == CrewSessionStatus.Active)
  62. .OrderByDescending(s => s.CreatedAt)
  63. .Select(s => (int?)s.ID)
  64. .FirstOrDefaultAsync(ct);
  65. }
  66. }
  67. // Donation 생성 (Wallet/PG 우회 — Send Handler와 다른 흐름)
  68. var donation = Domain.Entities.Donations.Donation.Create(
  69. sponsorMemberID: channel.MemberID,
  70. receiverMemberID: channel.MemberID,
  71. channelID: channel.ID,
  72. amount: r.Amount,
  73. feeRate: channel.PlatformFeeRate,
  74. message: r.Message,
  75. sendName: r.SendName,
  76. crewSessionID: crewSessionID,
  77. crewMemberID: r.CrewMemberID,
  78. isTest: r.IsTest
  79. );
  80. db.Donation.Add(donation);
  81. await db.SaveChangesAsync(ct);
  82. // DonationAlert 생성
  83. var alert = Domain.Entities.Donations.DonationAlert.Create(
  84. donationID: donation.ID,
  85. sponsorMemberID: channel.MemberID,
  86. receiverMemberID: channel.MemberID
  87. );
  88. db.DonationAlert.Add(alert);
  89. await db.SaveChangesAsync(ct);
  90. // 첫 Alert Attempt 기록 (재전송 일관성)
  91. var firstAttempt = DonationAlertAttempt.Create(donation.ID, alert.ID);
  92. db.DonationAlertAttempt.Add(firstAttempt);
  93. // 영구 반영(IsTest=false)일 때만 크루 집계 갱신 — 테스트 모드는 SignalR로만 broadcast
  94. if (!r.IsTest && crewSessionID.HasValue && r.CrewMemberID.HasValue)
  95. {
  96. var summary = await db.CrewDonationSummary
  97. .FirstOrDefaultAsync(s => s.CrewSessionID == crewSessionID.Value && s.CrewMemberID == r.CrewMemberID.Value, ct);
  98. if (summary is null)
  99. {
  100. summary = CrewDonationSummary.Create(crewSessionID.Value, r.CrewMemberID.Value);
  101. db.CrewDonationSummary.Add(summary);
  102. }
  103. summary.AddDonation(r.Amount);
  104. var session = await db.CrewSession.FindAsync([crewSessionID.Value], ct);
  105. session?.AddDonation(r.Amount);
  106. if (session is not null && session.TotalAmount > 0)
  107. {
  108. var allSummaries = await db.CrewDonationSummary
  109. .Where(s => s.CrewSessionID == session.ID)
  110. .OrderByDescending(s => s.TotalAmount)
  111. .ToListAsync(ct);
  112. var rank = 1;
  113. foreach (var s in allSummaries)
  114. {
  115. var rate = (decimal)s.TotalAmount / session.TotalAmount * 100;
  116. s.UpdateContribution(rate, rank++);
  117. }
  118. }
  119. }
  120. await db.SaveChangesAsync(ct);
  121. return Result.Success(new Response(
  122. donation.ID,
  123. alert.ID,
  124. alert.CorrelationID,
  125. donation.Amount,
  126. donation.FeeAmount,
  127. donation.NetAmount,
  128. r.SendName,
  129. r.Message,
  130. channel.ID,
  131. channel.Name,
  132. r.WidgetToken,
  133. crewSessionID,
  134. r.CrewMemberID,
  135. crewMemberNickname,
  136. r.IsTest
  137. ));
  138. }
  139. }