| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172 |
- using Application.Abstractions.Data;
- using Application.Abstractions.Messaging;
- using Domain.Entities.Common.ValueObject;
- using Domain.Entities.Donations;
- using Domain.Entities.Donations.ValueObject;
- using Microsoft.EntityFrameworkCore;
- namespace Application.Features.Api.Donation.Send;
- internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Response>
- {
- public async Task<Response> Handle(Command request, CancellationToken ct)
- {
- // 1. 채널 조회 + 수수료율
- var channel = await db.Channel
- .AsNoTracking()
- .Where(c => c.ID == request.ChannelID && c.IsActive)
- .Select(c => new { c.ID, c.MemberID, c.PlatformFeeRate })
- .FirstOrDefaultAsync(ct);
- if (channel is null)
- {
- throw new KeyNotFoundException("채널을 찾을 수 없습니다.");
- }
- // 2. 자기 자신에게 후원 불가
- if (request.SponsorMemberID == channel.MemberID && !request.IsTest)
- {
- throw new InvalidOperationException("본인 채널에 후원할 수 없습니다.");
- }
- // 3. 후원 수락 상태 확인
- var meta = await db.DonationMeta
- .AsNoTracking()
- .FirstOrDefaultAsync(m => m.ChannelID == request.ChannelID, ct);
- if (meta is not null && !meta.IsAccepting)
- {
- throw new InvalidOperationException("현재 후원을 받지 않는 채널입니다.");
- }
- var minAmount = meta?.MinAmount ?? DonationConstants.MinAmount;
- if (request.Amount < minAmount)
- {
- throw new InvalidOperationException($"최소 후원 금액은 {minAmount}원입니다.");
- }
- if (request.Amount > DonationConstants.MaxAmount)
- {
- throw new InvalidOperationException($"최대 후원 금액은 {DonationConstants.MaxAmount:N0}원입니다.");
- }
- // 4. 크루 세션 검증 (크루 후원인 경우)
- if (request.CrewSessionID.HasValue)
- {
- var session = await db.CrewSession
- .AsNoTracking()
- .FirstOrDefaultAsync(s => s.ID == request.CrewSessionID.Value && s.Status == CrewSessionStatus.Active, ct);
- if (session is null)
- {
- throw new InvalidOperationException("활성화된 크루 후원 방송이 아닙니다.");
- }
- }
- // 5. 후원자 지갑 조회 + 잔액 차감
- var sponsorWallet = await db.Wallet
- .Include(w => w.Balances)
- .FirstOrDefaultAsync(w => w.MemberID == request.SponsorMemberID, ct);
- if (sponsorWallet is null)
- {
- throw new KeyNotFoundException("지갑을 찾을 수 없습니다.");
- }
- var spendAmount = Money.KRW(request.Amount);
- var totalAvailable = sponsorWallet.GetTotalAvailable();
- if (totalAvailable.Value < spendAmount.Value)
- {
- throw new InvalidOperationException("잔액이 부족합니다.");
- }
- // 6. Donation 생성 (수수료 스냅샷)
- var donation = Domain.Entities.Donations.Donation.Create(
- sponsorMemberID: request.SponsorMemberID,
- receiverMemberID: channel.MemberID,
- channelID: channel.ID,
- amount: request.Amount,
- feeRate: channel.PlatformFeeRate,
- message: request.Message,
- sendName: request.SendName,
- crewSessionID: request.CrewSessionID,
- crewMemberID: request.CrewMemberID,
- isTest: request.IsTest
- );
- db.Donation.Add(donation);
- // 7. 후원자 지갑 차감
- if (!request.IsTest)
- {
- sponsorWallet.Spend(spendAmount, "DONATION_OUT", donation.ID.ToString());
- }
- // 8. 수신자 지갑에 입금 (NetAmount)
- if (!request.IsTest)
- {
- var receiverWallet = await db.Wallet
- .Include(w => w.Balances)
- .FirstOrDefaultAsync(w => w.MemberID == channel.MemberID, ct);
- if (receiverWallet is not null)
- {
- receiverWallet.CreditDonationIn(Money.KRW(donation.NetAmount), "DONATION_IN", donation.ID.ToString());
- }
- }
- // SaveChanges로 Donation ID 확보
- await db.SaveChangesAsync(ct);
- // 9. DonationAlert 생성
- var alert = Domain.Entities.Donations.DonationAlert.Create(
- donationID: donation.ID,
- sponsorMemberID: request.SponsorMemberID,
- receiverMemberID: channel.MemberID
- );
- db.DonationAlert.Add(alert);
- // 10. 크루 후원인 경우 집계 갱신
- if (request.CrewSessionID.HasValue && request.CrewMemberID.HasValue)
- {
- var summary = await db.CrewDonationSummary
- .FirstOrDefaultAsync(s => s.CrewSessionID == request.CrewSessionID.Value
- && s.CrewMemberID == request.CrewMemberID.Value, ct);
- if (summary is null)
- {
- summary = CrewDonationSummary.Create(request.CrewSessionID.Value, request.CrewMemberID.Value);
- db.CrewDonationSummary.Add(summary);
- }
- summary.AddDonation(request.Amount);
- // 세션 전체 합계 갱신
- var session = await db.CrewSession.FindAsync([request.CrewSessionID.Value], ct);
- session?.AddDonation(request.Amount);
- // 기여도 재계산
- if (session is not null && session.TotalAmount > 0)
- {
- var allSummaries = await db.CrewDonationSummary
- .Where(s => s.CrewSessionID == session.ID)
- .OrderByDescending(s => s.TotalAmount)
- .ToListAsync(ct);
- var rank = 1;
- foreach (var s in allSummaries)
- {
- var rate = (decimal)s.TotalAmount / session.TotalAmount * 100;
- s.UpdateContribution(rate, rank++);
- }
- }
- }
- await db.SaveChangesAsync(ct);
- // SignalR Push는 Endpoint 레이어 또는 별도 서비스에서 처리
- return new Response(donation.ID, alert.CorrelationID, donation.Amount, donation.FeeAmount, donation.NetAmount);
- }
- }
|