using Application.Abstractions.Data; using Application.Abstractions.Messaging; using SharedKernel.Results; using Domain.Entities.Donations; using Domain.Entities.Donations.ValueObject; using Microsoft.EntityFrameworkCore; namespace Application.Features.Api.DonationRemote.TestDonate; internal sealed class Handler(IAppDbContext db) : ICommandHandler> { public async Task> Handle(Command r, CancellationToken ct) { if (r.Amount <= 0) { return Result.Failure(Error.Problem("Donation.InvalidAmount", "금액은 0보다 커야 합니다.")); } if (r.Amount < DonationConstants.MinAmount) { return Result.Failure(Error.Problem("Donation.BelowMinAmount", $"최소 후원 금액은 {DonationConstants.MinAmount}원입니다.")); } if (r.Amount > DonationConstants.MaxAmount) { return Result.Failure(Error.Problem("Donation.AboveMaxAmount", $"최대 후원 금액은 {DonationConstants.MaxAmount:N0}원입니다.")); } if (string.IsNullOrWhiteSpace(r.SendName)) { return Result.Failure(Error.Problem("Donation.SendNameRequired", "별명이 필요합니다.")); } var channel = await db.Channel.AsNoTracking() .Where(c => c.WidgetToken == r.WidgetToken && c.IsActive) .Select(c => new { c.ID, c.MemberID, c.PlatformFeeRate, c.Name }) .FirstOrDefaultAsync(ct); if (channel is null) { return Result.Failure(Error.NotFound("Channel.NotFound", "채널을 찾을 수 없습니다.")); } if (channel.MemberID != r.RequesterMemberID) { return Result.Failure(Error.Forbidden("Remote.Forbidden", "권한이 없습니다.")); } // 크루 후원 검증 + 활성 세션 조회 (영구 반영 시에만 세션 필요) int? crewSessionID = null; string? crewMemberNickname = null; if (r.CrewMemberID.HasValue) { var crewMember = await db.CrewMember.AsNoTracking() .Where(m => m.ID == r.CrewMemberID.Value && m.IsActive) .Select(m => new { m.ID, m.CrewID, m.Nickname, CrewChannelID = m.Crew!.ChannelID }) .FirstOrDefaultAsync(ct); if (crewMember is null) { return Result.Failure(Error.NotFound("CrewMember.NotFound", "크루원을 찾을 수 없습니다.")); } if (crewMember.CrewChannelID != channel.ID) { return Result.Failure(Error.Forbidden("CrewMember.Forbidden", "다른 채널의 크루원입니다.")); } crewMemberNickname = crewMember.Nickname; if (!r.IsTest) { crewSessionID = await db.CrewSession.AsNoTracking() .Where(s => s.CrewID == crewMember.CrewID && s.Status == CrewSessionStatus.Active) .OrderByDescending(s => s.CreatedAt) .Select(s => (int?)s.ID) .FirstOrDefaultAsync(ct); } } // Donation 생성 (Wallet/PG 우회 — Send Handler와 다른 흐름) var donation = Domain.Entities.Donations.Donation.Create( sponsorMemberID: channel.MemberID, receiverMemberID: channel.MemberID, channelID: channel.ID, amount: r.Amount, feeRate: channel.PlatformFeeRate, message: r.Message, sendName: r.SendName, crewSessionID: crewSessionID, crewMemberID: r.CrewMemberID, isTest: r.IsTest ); db.Donation.Add(donation); await db.SaveChangesAsync(ct); // DonationAlert 생성 var alert = Domain.Entities.Donations.DonationAlert.Create( donationID: donation.ID, sponsorMemberID: channel.MemberID, receiverMemberID: channel.MemberID ); db.DonationAlert.Add(alert); await db.SaveChangesAsync(ct); // 첫 Alert Attempt 기록 (재전송 일관성) var firstAttempt = DonationAlertAttempt.Create(donation.ID, alert.ID); db.DonationAlertAttempt.Add(firstAttempt); // 영구 반영(IsTest=false)일 때만 크루 집계 갱신 — 테스트 모드는 SignalR로만 broadcast if (!r.IsTest && crewSessionID.HasValue && r.CrewMemberID.HasValue) { var summary = await db.CrewDonationSummary .FirstOrDefaultAsync(s => s.CrewSessionID == crewSessionID.Value && s.CrewMemberID == r.CrewMemberID.Value, ct); if (summary is null) { summary = CrewDonationSummary.Create(crewSessionID.Value, r.CrewMemberID.Value); db.CrewDonationSummary.Add(summary); } summary.AddDonation(r.Amount); var session = await db.CrewSession.FindAsync([crewSessionID.Value], ct); session?.AddDonation(r.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); return Result.Success(new Response( donation.ID, alert.ID, alert.CorrelationID, donation.Amount, donation.FeeAmount, donation.NetAmount, r.SendName, r.Message, channel.ID, channel.Name, r.WidgetToken, crewSessionID, r.CrewMemberID, crewMemberNickname, r.IsTest )); } }