DonationBroadcastHelper.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Hub;
  3. using Domain.Entities.Donations.ValueObject;
  4. using Microsoft.AspNetCore.SignalR;
  5. using Microsoft.EntityFrameworkCore;
  6. using Microsoft.Extensions.Logging;
  7. namespace Infrastructure.Hubs;
  8. /// <summary>알림 재생 종료 시점에 위젯에 전달하는 "현재 후원" 정보. IsTest 처리 핵심.</summary>
  9. public sealed record CurrentDonation(
  10. int Amount,
  11. int NetAmount,
  12. int SponsorMemberID,
  13. string SendName,
  14. int? CrewMemberID,
  15. bool IsTest
  16. );
  17. /// <summary>
  18. /// 후원 발생 시 OBS 위젯(Goal/Rank/Crew)에 실시간 갱신 broadcast.
  19. ///
  20. /// IsTest 처리 정책:
  21. /// • 영구 합산 = IsTest=false 후원만 GROUP BY (DB의 영구 데이터 sources of truth와 동일)
  22. /// • 현재 후원이 IsTest=true이면: 영구 합산에 "현재 1건만" 추가해서 broadcast (single-shot 효과)
  23. /// • 현재 후원이 IsTest=false이면: 이미 영구 합산에 포함됨 — 추가 불필요
  24. ///
  25. /// 결과:
  26. /// • 테스트 후원 시 위젯에 1회성 추가 표시 → 새로고침 후 사라짐
  27. /// • 다음 테스트 후원 시 영구 + 새 1건만 (이전 테스트 누적 X)
  28. ///
  29. /// 모든 broadcast 실패는 swallow — 후원 본 처리에 영향 주지 않음.
  30. /// </summary>
  31. internal static class DonationBroadcastHelper
  32. {
  33. public static async Task BroadcastGoalAndRankAsync(
  34. IHubContext<DonationHub, IDonationHubClient> hub,
  35. IAppDbContext db,
  36. int channelID,
  37. string widgetToken,
  38. int? crewMemberID,
  39. CurrentDonation? currentDonation,
  40. CancellationToken ct,
  41. ILogger? logger = null
  42. )
  43. {
  44. if (string.IsNullOrEmpty(widgetToken) || channelID <= 0)
  45. {
  46. logger?.LogWarning("[DonationBroadcast] skip — empty token or invalid channelID (channelID={ChannelID}, token={Token})", channelID, widgetToken);
  47. return;
  48. }
  49. logger?.LogInformation("[DonationBroadcast] start — channelID={ChannelID}, token={Token}, crewMemberID={CrewMemberID}, isTest={IsTest}",
  50. channelID, widgetToken, crewMemberID, currentDonation?.IsTest);
  51. try
  52. {
  53. await BroadcastGoalAsync(hub, db, channelID, widgetToken, currentDonation, ct, logger);
  54. await BroadcastRankAsync(hub, db, channelID, widgetToken, currentDonation, ct, logger);
  55. if (crewMemberID.HasValue)
  56. {
  57. await BroadcastCrewAsync(hub, db, channelID, widgetToken, crewMemberID.Value, currentDonation, ct, logger);
  58. }
  59. logger?.LogInformation("[DonationBroadcast] done — channelID={ChannelID}", channelID);
  60. }
  61. catch (Exception ex)
  62. {
  63. logger?.LogWarning(ex, "[DonationBroadcast] failed — channelID={ChannelID}", channelID);
  64. }
  65. }
  66. private static async Task BroadcastGoalAsync(
  67. IHubContext<DonationHub, IDonationHubClient> hub,
  68. IAppDbContext db, int channelID, string widgetToken,
  69. CurrentDonation? currentDonation,
  70. CancellationToken ct,
  71. ILogger? logger
  72. )
  73. {
  74. var goal = await db.DonationGoalConfig.AsNoTracking()
  75. .Where(g => g.ChannelID == channelID && g.IsActive)
  76. .OrderByDescending(g => g.ID)
  77. .Select(g => new { g.ID, g.Title, g.Period, g.StartAmount, g.TargetAmount, g.StartAt, g.EndAt })
  78. .FirstOrDefaultAsync(ct);
  79. if (goal is null)
  80. {
  81. logger?.LogInformation("[DonationBroadcast] Goal: no active config — skip");
  82. return;
  83. }
  84. var now = DateTime.UtcNow;
  85. DateTime? rangeStart = goal.Period switch
  86. {
  87. RankPeriodType.Daily => now.Date,
  88. RankPeriodType.Weekly => now.Date.AddDays(-(int)now.DayOfWeek),
  89. RankPeriodType.Monthly => new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc),
  90. RankPeriodType.Yearly => new DateTime(now.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc),
  91. RankPeriodType.Custom => goal.StartAt,
  92. _ => null
  93. };
  94. DateTime? rangeEnd = goal.Period == RankPeriodType.Custom ? goal.EndAt : null;
  95. // 영구 합산 (IsTest=false만)
  96. var permanentAmount = await db.Donation.AsNoTracking()
  97. .Where(d => d.ChannelID == channelID && !d.IsTest
  98. && (rangeStart == null || d.CreatedAt >= rangeStart)
  99. && (rangeEnd == null || d.CreatedAt <= rangeEnd))
  100. .SumAsync(d => d.Amount, ct);
  101. // 현재 후원이 IsTest=true이면 "이 1건만" 추가 (영구는 이미 위에서 합산됨)
  102. var testExtra = currentDonation?.IsTest == true ? currentDonation.Amount : 0;
  103. var adjusted = permanentAmount + testExtra + goal.StartAmount;
  104. var percent = goal.TargetAmount > 0 ? Math.Min((decimal)adjusted / goal.TargetAmount * 100, 100) : 0;
  105. await hub.Clients.Group(widgetToken).ReceiveGoalUpdate(new
  106. {
  107. goalConfigID = goal.ID,
  108. title = goal.Title,
  109. startAmount = goal.StartAmount,
  110. targetAmount = goal.TargetAmount,
  111. currentAmount = adjusted,
  112. percent = Math.Round(percent, 1)
  113. });
  114. logger?.LogInformation("[DonationBroadcast] Goal sent — configID={ConfigID}, permanent={Permanent}, testExtra={TestExtra}, current={Current}",
  115. goal.ID, permanentAmount, testExtra, adjusted);
  116. }
  117. private static async Task BroadcastRankAsync(
  118. IHubContext<DonationHub, IDonationHubClient> hub,
  119. IAppDbContext db, int channelID, string widgetToken,
  120. CurrentDonation? currentDonation,
  121. CancellationToken ct,
  122. ILogger? logger
  123. )
  124. {
  125. var rankCfg = await db.DonationRankConfig.AsNoTracking()
  126. .Where(r => r.ChannelID == channelID && r.IsActive)
  127. .OrderByDescending(r => r.ID)
  128. .Select(r => new { r.Period, r.MaxRankCount, r.StartAt, r.EndAt })
  129. .FirstOrDefaultAsync(ct);
  130. var period = rankCfg?.Period ?? RankPeriodType.Daily;
  131. var limit = rankCfg?.MaxRankCount ?? 5;
  132. var now = DateTime.UtcNow;
  133. DateTime? rangeStart = period switch
  134. {
  135. RankPeriodType.Daily => now.Date,
  136. RankPeriodType.Weekly => now.Date.AddDays(-(int)now.DayOfWeek),
  137. RankPeriodType.Monthly => new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc),
  138. RankPeriodType.Yearly => new DateTime(now.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc),
  139. RankPeriodType.Custom => rankCfg?.StartAt,
  140. _ => null
  141. };
  142. DateTime? rangeEnd = period == RankPeriodType.Custom ? rankCfg?.EndAt : null;
  143. // 영구 GROUP BY (IsTest=false만)
  144. var donationQuery = db.Donation.AsNoTracking()
  145. .Where(d => d.ChannelID == channelID && !d.IsTest);
  146. if (rangeStart.HasValue)
  147. {
  148. donationQuery = donationQuery.Where(d => d.CreatedAt >= rangeStart.Value);
  149. }
  150. if (rangeEnd.HasValue)
  151. {
  152. donationQuery = donationQuery.Where(d => d.CreatedAt <= rangeEnd.Value);
  153. }
  154. var permanentList = await donationQuery
  155. .GroupBy(d => d.SponsorMemberID)
  156. .Select(g => new
  157. {
  158. SponsorMemberID = g.Key,
  159. SendName = g.OrderByDescending(d => d.CreatedAt).Select(d => d.SendName).FirstOrDefault() ?? "",
  160. TotalAmount = g.Sum(d => d.NetAmount),
  161. DonationCount = g.Count()
  162. })
  163. .ToListAsync(ct);
  164. // 가공 가능한 list로 변환 (mutable)
  165. var working = permanentList.Select(r => new RankRow
  166. {
  167. SponsorMemberID = r.SponsorMemberID,
  168. SendName = r.SendName,
  169. TotalAmount = r.TotalAmount,
  170. DonationCount = r.DonationCount
  171. }).ToList();
  172. // 현재 후원이 IsTest=true이면 단일 추가/병합
  173. if (currentDonation?.IsTest == true)
  174. {
  175. var existing = working.FirstOrDefault(x => x.SponsorMemberID == currentDonation.SponsorMemberID);
  176. if (existing is not null)
  177. {
  178. existing.TotalAmount += currentDonation.NetAmount;
  179. existing.DonationCount += 1;
  180. existing.SendName = currentDonation.SendName; // 최근 별명
  181. }
  182. else
  183. {
  184. working.Add(new RankRow
  185. {
  186. SponsorMemberID = currentDonation.SponsorMemberID,
  187. SendName = currentDonation.SendName,
  188. TotalAmount = currentDonation.NetAmount,
  189. DonationCount = 1
  190. });
  191. }
  192. }
  193. var list = working
  194. .OrderByDescending(x => x.TotalAmount)
  195. .Take(limit)
  196. .Select((r, i) => new
  197. {
  198. rank = i + 1,
  199. sponsorMemberID = r.SponsorMemberID,
  200. sponsorName = r.SendName,
  201. totalAmount = r.TotalAmount,
  202. donationCount = r.DonationCount
  203. }).ToList();
  204. await hub.Clients.Group(widgetToken).ReceiveRankUpdate(new { list });
  205. logger?.LogInformation("[DonationBroadcast] Rank sent — count={Count}, testExtra={TestExtra}",
  206. list.Count, currentDonation?.IsTest == true ? currentDonation.Amount : 0);
  207. }
  208. private sealed class RankRow
  209. {
  210. public int SponsorMemberID { get; set; }
  211. public string SendName { get; set; } = "";
  212. public int TotalAmount { get; set; }
  213. public int DonationCount { get; set; }
  214. }
  215. private static async Task BroadcastCrewAsync(
  216. IHubContext<DonationHub, IDonationHubClient> hub,
  217. IAppDbContext db, int channelID, string widgetToken, int crewMemberID,
  218. CurrentDonation? currentDonation,
  219. CancellationToken ct,
  220. ILogger? logger
  221. )
  222. {
  223. var crewID = await db.CrewMember.AsNoTracking()
  224. .Where(m => m.ID == crewMemberID)
  225. .Select(m => (int?)m.CrewID)
  226. .FirstOrDefaultAsync(ct);
  227. if (crewID is null)
  228. {
  229. logger?.LogInformation("[DonationBroadcast] Crew: crewMember {ID} not found — skip", crewMemberID);
  230. return;
  231. }
  232. var widgetCfg = await db.CrewWidgetConfig.AsNoTracking()
  233. .Where(w => w.ChannelID == channelID && w.CrewID == crewID.Value && w.IsActive)
  234. .OrderByDescending(w => w.ID)
  235. .Select(w => new { w.MaxDisplayCount })
  236. .FirstOrDefaultAsync(ct);
  237. if (widgetCfg is null)
  238. {
  239. logger?.LogInformation("[DonationBroadcast] Crew: no active widget config for crewID={CrewID} — skip", crewID.Value);
  240. return;
  241. }
  242. var baseMembers = await db.CrewMember.AsNoTracking()
  243. .Where(m => m.CrewID == crewID.Value && m.IsActive)
  244. .OrderBy(m => m.SortOrder).ThenBy(m => m.JoinedAt)
  245. .Select(m => new
  246. {
  247. ID = m.ID,
  248. Nickname = m.Nickname,
  249. Icon = m.Channel != null && m.Channel.ThumbnailUrl != null
  250. ? m.Channel.ThumbnailUrl
  251. : (m.Member != null ? m.Member.Thumb : null),
  252. ChannelName = m.Channel != null ? m.Channel.Name : null
  253. })
  254. .ToListAsync(ct);
  255. // 영구 GROUP BY (IsTest=false만)
  256. var donationDict = (await db.Donation.AsNoTracking()
  257. .Where(d => d.ChannelID == channelID && !d.IsTest && d.CrewMemberID != null
  258. && db.CrewMember.Any(m => m.ID == d.CrewMemberID && m.CrewID == crewID.Value))
  259. .GroupBy(d => d.CrewMemberID!.Value)
  260. .Select(g => new
  261. {
  262. CrewMemberID = g.Key,
  263. TotalAmount = g.Sum(d => d.NetAmount),
  264. DonationCount = g.Count()
  265. })
  266. .ToListAsync(ct)).ToDictionary(x => x.CrewMemberID, x => new CrewRow
  267. {
  268. TotalAmount = x.TotalAmount,
  269. DonationCount = x.DonationCount
  270. });
  271. // 현재 후원이 IsTest=true이고 crewMemberID가 base에 있으면 단일 추가
  272. if (currentDonation?.IsTest == true && currentDonation.CrewMemberID.HasValue
  273. && baseMembers.Any(m => m.ID == currentDonation.CrewMemberID.Value))
  274. {
  275. var key = currentDonation.CrewMemberID.Value;
  276. if (donationDict.TryGetValue(key, out var existing))
  277. {
  278. existing.TotalAmount += currentDonation.NetAmount;
  279. existing.DonationCount += 1;
  280. }
  281. else
  282. {
  283. donationDict[key] = new CrewRow
  284. {
  285. TotalAmount = currentDonation.NetAmount,
  286. DonationCount = 1
  287. };
  288. }
  289. }
  290. var totalAmount = donationDict.Values.Sum(x => x.TotalAmount);
  291. var merged = baseMembers.Select(m =>
  292. {
  293. var d = donationDict.GetValueOrDefault(m.ID);
  294. var amount = d?.TotalAmount ?? 0;
  295. var contributionRate = totalAmount > 0 ? (decimal)amount / totalAmount * 100 : 0;
  296. return new
  297. {
  298. crewMemberID = m.ID,
  299. nickname = m.Nickname,
  300. icon = m.Icon,
  301. channelName = m.ChannelName,
  302. totalAmount = amount,
  303. donationCount = d?.DonationCount ?? 0,
  304. contributionRate = Math.Round(contributionRate, 1)
  305. };
  306. })
  307. .OrderByDescending(x => x.totalAmount)
  308. .Take(widgetCfg.MaxDisplayCount)
  309. .ToList();
  310. var list = merged.Select((x, i) => new
  311. {
  312. rank = i + 1,
  313. crewMemberID = x.crewMemberID,
  314. nickname = x.nickname,
  315. icon = x.icon,
  316. channelName = x.channelName,
  317. totalAmount = x.totalAmount,
  318. donationCount = x.donationCount,
  319. contributionRate = x.contributionRate
  320. }).ToList();
  321. await hub.Clients.Group(widgetToken).ReceiveCrewUpdate(new { list, totalAmount });
  322. logger?.LogInformation("[DonationBroadcast] Crew sent — crewID={CrewID}, count={Count}, total={Total}",
  323. crewID.Value, list.Count, totalAmount);
  324. }
  325. private sealed class CrewRow
  326. {
  327. public int TotalAmount { get; set; }
  328. public int DonationCount { get; set; }
  329. }
  330. }