| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365 |
- 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;
- /// <summary>알림 재생 종료 시점에 위젯에 전달하는 "현재 후원" 정보. IsTest 처리 핵심.</summary>
- public sealed record CurrentDonation(
- int Amount,
- int NetAmount,
- int SponsorMemberID,
- string SendName,
- int? CrewMemberID,
- bool IsTest
- );
- /// <summary>
- /// 후원 발생 시 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 — 후원 본 처리에 영향 주지 않음.
- /// </summary>
- internal static class DonationBroadcastHelper
- {
- public static async Task BroadcastGoalAndRankAsync(
- IHubContext<DonationHub, IDonationHubClient> 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<DonationHub, IDonationHubClient> 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<DonationHub, IDonationHubClient> 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<DonationHub, IDonationHubClient> 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; }
- }
- }
|