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; }
}
}