using Application.Abstractions.Cache; using Application.Abstractions.Channels; using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Application.Helpers; using SharedKernel.Results; using Domain.Entities.Common.ValueObject; using Domain.Entities.Donations; using Domain.Entities.Donations.ValueObject; using Domain.Entities.Members.Logs; using Microsoft.EntityFrameworkCore; namespace Application.Features.Api.Donation.Send; internal sealed class Handler(IAppDbContext db, ICacheService cache) : ICommandHandler> { public async Task> Handle(Command request, CancellationToken ct) { // 0. ChannelSID/@handle → ChannelID 변환 (ChannelID 미지정 시) if (request.ChannelID == 0 && !string.IsNullOrEmpty(request.ChannelSID)) { var resolvedIdentifier = await ChannelIdentifierResolver.ResolveAsync(db, request.ChannelSID, ct); if (resolvedIdentifier.ChannelSID is null) { return Result.Failure(Error.NotFound("Channel.NotFound", "채널을 찾을 수 없습니다.")); } var resolved = await db.Channel.AsNoTracking() .Where(c => c.SID == resolvedIdentifier.ChannelSID) .Select(c => c.ID) .FirstOrDefaultAsync(ct); if (resolved == 0) { return Result.Failure(Error.NotFound("Channel.NotFound", "채널을 찾을 수 없습니다.")); } request = request with { ChannelID = resolved }; } // 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) { return Result.Failure(Error.NotFound("Channel.NotFound", "채널을 찾을 수 없습니다.")); } // 2. 자기 자신에게 후원 불가 if (request.SponsorMemberID == channel.MemberID && !request.IsTest) { return Result.Failure(Error.Problem("Donation.SelfDonationNotAllowed", "본인 채널에 후원할 수 없습니다.")); } // 3. 후원 수락 상태 확인 var meta = await db.DonationMeta .AsNoTracking() .FirstOrDefaultAsync(m => m.ChannelID == request.ChannelID, ct); if (meta is not null && !meta.IsAccepting) { return Result.Failure(Error.Problem("Donation.NotAccepting", "현재 후원을 받지 않는 채널입니다.")); } var minAmount = meta?.MinAmount ?? DonationConstants.MinAmount; if (request.Amount < minAmount) { return Result.Failure(Error.Problem("Donation.BelowMinAmount", $"최소 후원 금액은 {minAmount}원입니다.")); } if (request.Amount > DonationConstants.MaxAmount) { return Result.Failure(Error.Problem("Donation.AboveMaxAmount", $"최대 후원 금액은 {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) { return Result.Failure(Error.Problem("Crew.SessionNotActive", "활성화된 크루 후원 방송이 아닙니다.")); } } // 5. 후원자 지갑 조회 + 잔액 차감 var sponsorWallet = await db.Wallet .Include(w => w.Balances) .FirstOrDefaultAsync(w => w.MemberID == request.SponsorMemberID, ct); if (sponsorWallet is null) { return Result.Failure(Error.NotFound("Wallet.NotFound", "지갑을 찾을 수 없습니다.")); } var spendAmount = Money.KRW(request.Amount); var totalAvailable = sponsorWallet.GetTotalAvailable(); if (totalAvailable.Value < spendAmount.Value) { return Result.Failure(Error.Problem("Wallet.InsufficientBalance", "잔액이 부족합니다.")); } // 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); // 9-1. 첫 Alert Attempt 생성 (재전송 로직 일관성 확보) await db.SaveChangesAsync(ct); var firstAttempt = DonationAlertAttempt.Create(donation.ID, alert.ID); db.DonationAlertAttempt.Add(firstAttempt); // 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++); } } } // 11. 후원자×채널 누적 집계 (채널 칭호 획득 판정용) if (!request.IsTest) { var stats = await db.DonorChannelStats .FirstOrDefaultAsync(s => s.DonorMemberID == request.SponsorMemberID && s.ChannelID == channel.ID, ct); if (stats is null) { stats = DonorChannelStats.Create(request.SponsorMemberID, channel.ID); await db.DonorChannelStats.AddAsync(stats, ct); } stats.AddDonation(donation.Amount, donation.CreatedAt); } await db.SaveChangesAsync(ct); // 12. 후원 XP 적립 (방송 세션 활성 시 리더보드 집계 대상) if (!request.IsTest) { var cfg = await ChatExpConfigLoader.GetAsync(cache, db, ct); if (cfg.DonationXpPerAmount > 0) { var xpGrant = (int)(request.Amount / cfg.DonationXpPerAmount); if (xpGrant > 0) { var session = await db.BroadcastSession.AsNoTracking() .Where(s => s.ChannelID == channel.ID && !s.IsFinalized) .OrderByDescending(s => s.CreatedAt) .Select(s => new { s.ID }) .FirstOrDefaultAsync(ct); var stats = await db.MemberStats.FirstOrDefaultAsync(s => s.MemberID == request.SponsorMemberID, ct); if (stats is not null) { stats.Exp += xpGrant; var expLog = session is not null ? MemberExpLog.CreateForBroadcast(request.SponsorMemberID, "후원(라이브)", xpGrant, stats.Exp, session.ID, channel.ID) : MemberExpLog.Create(request.SponsorMemberID, "후원", xpGrant, stats.Exp); db.MemberExpLog.Add(expLog); await db.SaveChangesAsync(ct); } } } } // SignalR Push는 Endpoint 레이어에서 처리 (ReceiveAlert) return Result.Success(new Response(donation.ID, alert.ID, alert.CorrelationID, donation.Amount, donation.FeeAmount, donation.NetAmount)); } }