using Application.Abstractions.Data; using Application.Abstractions.Hub; using Domain.Entities.Donations.ValueObject; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Infrastructure.Hubs; /// 알림 재생 종료 시점에 위젯에 전달하는 "현재 후원" 정보. IsTest 처리 핵심. public sealed record CurrentDonation( int Amount, int NetAmount, int SponsorMemberID, string SendName, int? CrewMemberID, bool IsTest ); /// /// 후원 발생 시 OBS 위젯(Goal/Rank/Crew)에 실시간 갱신 broadcast. /// /// IsTest 처리 정책: /// • 영구 합산 = IsTest=false 후원만 GROUP BY (DB의 영구 데이터 sources of truth와 동일) /// • 현재 후원이 IsTest=true이면: 영구 합산에 "현재 1건만" 추가해서 broadcast (single-shot 효과) /// • 현재 후원이 IsTest=false이면: 이미 영구 합산에 포함됨 — 추가 불필요 /// /// 결과: /// • 테스트 후원 시 위젯에 1회성 추가 표시 → 새로고침 후 사라짐 /// • 다음 테스트 후원 시 영구 + 새 1건만 (이전 테스트 누적 X) /// /// 모든 broadcast 실패는 swallow — 후원 본 처리에 영향 주지 않음. /// internal static class DonationBroadcastHelper { public static async Task BroadcastGoalAndRankAsync( IHubContext hub, IAppDbContext db, int channelID, string widgetToken, int? crewMemberID, CurrentDonation? currentDonation, CancellationToken ct, ILogger? logger = null ) { if (string.IsNullOrEmpty(widgetToken) || channelID <= 0) { logger?.LogWarning("[DonationBroadcast] skip — empty token or invalid channelID (channelID={ChannelID}, token={Token})", channelID, widgetToken); return; } logger?.LogInformation("[DonationBroadcast] start — channelID={ChannelID}, token={Token}, crewMemberID={CrewMemberID}, isTest={IsTest}", channelID, widgetToken, crewMemberID, currentDonation?.IsTest); try { await BroadcastGoalAsync(hub, db, channelID, widgetToken, currentDonation, ct, logger); await BroadcastRankAsync(hub, db, channelID, widgetToken, currentDonation, ct, logger); if (crewMemberID.HasValue) { await BroadcastCrewAsync(hub, db, channelID, widgetToken, crewMemberID.Value, currentDonation, ct, logger); } logger?.LogInformation("[DonationBroadcast] done — channelID={ChannelID}", channelID); } catch (Exception ex) { logger?.LogWarning(ex, "[DonationBroadcast] failed — channelID={ChannelID}", channelID); } } private static async Task BroadcastGoalAsync( IHubContext hub, IAppDbContext db, int channelID, string widgetToken, CurrentDonation? currentDonation, CancellationToken ct, ILogger? logger ) { var goal = await db.DonationGoalConfig.AsNoTracking() .Where(g => g.ChannelID == channelID && g.IsActive) .OrderByDescending(g => g.ID) .Select(g => new { g.ID, g.Title, g.Period, g.StartAmount, g.TargetAmount, g.StartAt, g.EndAt }) .FirstOrDefaultAsync(ct); if (goal is null) { logger?.LogInformation("[DonationBroadcast] Goal: no active config — skip"); return; } var now = DateTime.UtcNow; DateTime? rangeStart = goal.Period switch { RankPeriodType.Daily => now.Date, RankPeriodType.Weekly => now.Date.AddDays(-(int)now.DayOfWeek), RankPeriodType.Monthly => new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc), RankPeriodType.Yearly => new DateTime(now.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc), RankPeriodType.Custom => goal.StartAt, _ => null }; DateTime? rangeEnd = goal.Period == RankPeriodType.Custom ? goal.EndAt : null; // 영구 합산 (IsTest=false만) var permanentAmount = await db.Donation.AsNoTracking() .Where(d => d.ChannelID == channelID && !d.IsTest && (rangeStart == null || d.CreatedAt >= rangeStart) && (rangeEnd == null || d.CreatedAt <= rangeEnd)) .SumAsync(d => d.Amount, ct); // 현재 후원이 IsTest=true이면 "이 1건만" 추가 (영구는 이미 위에서 합산됨) var testExtra = currentDonation?.IsTest == true ? currentDonation.Amount : 0; var adjusted = permanentAmount + testExtra + goal.StartAmount; var percent = goal.TargetAmount > 0 ? Math.Min((decimal)adjusted / goal.TargetAmount * 100, 100) : 0; await hub.Clients.Group(widgetToken).ReceiveGoalUpdate(new { goalConfigID = goal.ID, title = goal.Title, startAmount = goal.StartAmount, targetAmount = goal.TargetAmount, currentAmount = adjusted, percent = Math.Round(percent, 1) }); logger?.LogInformation("[DonationBroadcast] Goal sent — configID={ConfigID}, permanent={Permanent}, testExtra={TestExtra}, current={Current}", goal.ID, permanentAmount, testExtra, adjusted); } private static async Task BroadcastRankAsync( IHubContext hub, IAppDbContext db, int channelID, string widgetToken, CurrentDonation? currentDonation, CancellationToken ct, ILogger? logger ) { var rankCfg = await db.DonationRankConfig.AsNoTracking() .Where(r => r.ChannelID == channelID && r.IsActive) .OrderByDescending(r => r.ID) .Select(r => new { r.Period, r.MaxRankCount, r.StartAt, r.EndAt }) .FirstOrDefaultAsync(ct); var period = rankCfg?.Period ?? RankPeriodType.Daily; var limit = rankCfg?.MaxRankCount ?? 5; var now = DateTime.UtcNow; DateTime? rangeStart = period switch { RankPeriodType.Daily => now.Date, RankPeriodType.Weekly => now.Date.AddDays(-(int)now.DayOfWeek), RankPeriodType.Monthly => new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc), RankPeriodType.Yearly => new DateTime(now.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc), RankPeriodType.Custom => rankCfg?.StartAt, _ => null }; DateTime? rangeEnd = period == RankPeriodType.Custom ? rankCfg?.EndAt : null; // 영구 GROUP BY (IsTest=false만) var donationQuery = db.Donation.AsNoTracking() .Where(d => d.ChannelID == channelID && !d.IsTest); if (rangeStart.HasValue) { donationQuery = donationQuery.Where(d => d.CreatedAt >= rangeStart.Value); } if (rangeEnd.HasValue) { donationQuery = donationQuery.Where(d => d.CreatedAt <= rangeEnd.Value); } var permanentList = await donationQuery .GroupBy(d => d.SponsorMemberID) .Select(g => new { SponsorMemberID = g.Key, SendName = g.OrderByDescending(d => d.CreatedAt).Select(d => d.SendName).FirstOrDefault() ?? "", TotalAmount = g.Sum(d => d.NetAmount), DonationCount = g.Count() }) .ToListAsync(ct); // 가공 가능한 list로 변환 (mutable) var working = permanentList.Select(r => new RankRow { SponsorMemberID = r.SponsorMemberID, SendName = r.SendName, TotalAmount = r.TotalAmount, DonationCount = r.DonationCount }).ToList(); // 현재 후원이 IsTest=true이면 단일 추가/병합 if (currentDonation?.IsTest == true) { var existing = working.FirstOrDefault(x => x.SponsorMemberID == currentDonation.SponsorMemberID); if (existing is not null) { existing.TotalAmount += currentDonation.NetAmount; existing.DonationCount += 1; existing.SendName = currentDonation.SendName; // 최근 별명 } else { working.Add(new RankRow { SponsorMemberID = currentDonation.SponsorMemberID, SendName = currentDonation.SendName, TotalAmount = currentDonation.NetAmount, DonationCount = 1 }); } } var list = working .OrderByDescending(x => x.TotalAmount) .Take(limit) .Select((r, i) => new { rank = i + 1, sponsorMemberID = r.SponsorMemberID, sponsorName = r.SendName, totalAmount = r.TotalAmount, donationCount = r.DonationCount }).ToList(); await hub.Clients.Group(widgetToken).ReceiveRankUpdate(new { list }); logger?.LogInformation("[DonationBroadcast] Rank sent — count={Count}, testExtra={TestExtra}", list.Count, currentDonation?.IsTest == true ? currentDonation.Amount : 0); } private sealed class RankRow { public int SponsorMemberID { get; set; } public string SendName { get; set; } = ""; public int TotalAmount { get; set; } public int DonationCount { get; set; } } private static async Task BroadcastCrewAsync( IHubContext hub, IAppDbContext db, int channelID, string widgetToken, int crewMemberID, CurrentDonation? currentDonation, CancellationToken ct, ILogger? logger ) { var crewID = await db.CrewMember.AsNoTracking() .Where(m => m.ID == crewMemberID) .Select(m => (int?)m.CrewID) .FirstOrDefaultAsync(ct); if (crewID is null) { logger?.LogInformation("[DonationBroadcast] Crew: crewMember {ID} not found — skip", crewMemberID); return; } var widgetCfg = await db.CrewWidgetConfig.AsNoTracking() .Where(w => w.ChannelID == channelID && w.CrewID == crewID.Value && w.IsActive) .OrderByDescending(w => w.ID) .Select(w => new { w.MaxDisplayCount }) .FirstOrDefaultAsync(ct); if (widgetCfg is null) { logger?.LogInformation("[DonationBroadcast] Crew: no active widget config for crewID={CrewID} — skip", crewID.Value); return; } var baseMembers = await db.CrewMember.AsNoTracking() .Where(m => m.CrewID == crewID.Value && m.IsActive) .OrderBy(m => m.SortOrder).ThenBy(m => m.JoinedAt) .Select(m => new { ID = m.ID, Nickname = m.Nickname, Icon = m.Channel != null && m.Channel.ThumbnailUrl != null ? m.Channel.ThumbnailUrl : (m.Member != null ? m.Member.Thumb : null), ChannelName = m.Channel != null ? m.Channel.Name : null }) .ToListAsync(ct); // 영구 GROUP BY (IsTest=false만) var donationDict = (await db.Donation.AsNoTracking() .Where(d => d.ChannelID == channelID && !d.IsTest && d.CrewMemberID != null && db.CrewMember.Any(m => m.ID == d.CrewMemberID && m.CrewID == crewID.Value)) .GroupBy(d => d.CrewMemberID!.Value) .Select(g => new { CrewMemberID = g.Key, TotalAmount = g.Sum(d => d.NetAmount), DonationCount = g.Count() }) .ToListAsync(ct)).ToDictionary(x => x.CrewMemberID, x => new CrewRow { TotalAmount = x.TotalAmount, DonationCount = x.DonationCount }); // 현재 후원이 IsTest=true이고 crewMemberID가 base에 있으면 단일 추가 if (currentDonation?.IsTest == true && currentDonation.CrewMemberID.HasValue && baseMembers.Any(m => m.ID == currentDonation.CrewMemberID.Value)) { var key = currentDonation.CrewMemberID.Value; if (donationDict.TryGetValue(key, out var existing)) { existing.TotalAmount += currentDonation.NetAmount; existing.DonationCount += 1; } else { donationDict[key] = new CrewRow { TotalAmount = currentDonation.NetAmount, DonationCount = 1 }; } } var totalAmount = donationDict.Values.Sum(x => x.TotalAmount); var merged = baseMembers.Select(m => { var d = donationDict.GetValueOrDefault(m.ID); var amount = d?.TotalAmount ?? 0; var contributionRate = totalAmount > 0 ? (decimal)amount / totalAmount * 100 : 0; return new { crewMemberID = m.ID, nickname = m.Nickname, icon = m.Icon, channelName = m.ChannelName, totalAmount = amount, donationCount = d?.DonationCount ?? 0, contributionRate = Math.Round(contributionRate, 1) }; }) .OrderByDescending(x => x.totalAmount) .Take(widgetCfg.MaxDisplayCount) .ToList(); var list = merged.Select((x, i) => new { rank = i + 1, crewMemberID = x.crewMemberID, nickname = x.nickname, icon = x.icon, channelName = x.channelName, totalAmount = x.totalAmount, donationCount = x.donationCount, contributionRate = x.contributionRate }).ToList(); await hub.Clients.Group(widgetToken).ReceiveCrewUpdate(new { list, totalAmount }); logger?.LogInformation("[DonationBroadcast] Crew sent — crewID={CrewID}, count={Count}, total={Total}", crewID.Value, list.Count, totalAmount); } private sealed class CrewRow { public int TotalAmount { get; set; } public int DonationCount { get; set; } } }