DonationHub.cs 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Hub;
  3. using Microsoft.AspNetCore.SignalR;
  4. using Microsoft.EntityFrameworkCore;
  5. using Microsoft.Extensions.Logging;
  6. namespace Infrastructure.Hubs;
  7. /// <summary>
  8. /// 후원 전용 Hub — OBS 위젯, 리모콘 전용
  9. /// 채널 WidgetToken 기반 그룹으로 분리
  10. /// </summary>
  11. public sealed class DonationHub(
  12. IAppDbContext db,
  13. ILogger<DonationHub> logger,
  14. IHubContext<DonationHub, IDonationHubClient> hubContext
  15. ) : Hub<IDonationHubClient>
  16. {
  17. public override async Task OnConnectedAsync()
  18. {
  19. logger.LogInformation("[DonationHub] Connected: {ConnectionId}", Context.ConnectionId);
  20. await base.OnConnectedAsync();
  21. }
  22. public override async Task OnDisconnectedAsync(Exception? exception)
  23. {
  24. logger.LogInformation("[DonationHub] Disconnected: {ConnectionId}", Context.ConnectionId);
  25. await base.OnDisconnectedAsync(exception);
  26. }
  27. /// <summary>채널 그룹 참가 (OBS 위젯, 리모콘에서 호출) — WidgetToken 유효성 검증 후에만 참가 허용</summary>
  28. public async Task JoinChannel(string widgetToken)
  29. {
  30. if (string.IsNullOrWhiteSpace(widgetToken))
  31. {
  32. logger.LogWarning("[DonationHub] Empty widgetToken rejected from {ConnectionId}", Context.ConnectionId);
  33. Context.Abort();
  34. return;
  35. }
  36. var channelID = await db.Channel.AsNoTracking().Where(c => c.WidgetToken == widgetToken && c.IsActive).Select(c => (int?)c.ID).FirstOrDefaultAsync(Context.ConnectionAborted);
  37. if (channelID is null)
  38. {
  39. logger.LogWarning("[DonationHub] Invalid widgetToken rejected from {ConnectionId}", Context.ConnectionId);
  40. Context.Abort();
  41. return;
  42. }
  43. await Groups.AddToGroupAsync(Context.ConnectionId, widgetToken);
  44. logger.LogInformation("[DonationHub] {ConnectionId} joined channel {ChannelID}", Context.ConnectionId, channelID);
  45. // 새로 join한 connection에 현재 메타 상태(일시정지/수신/음성/영상) 즉시 전송
  46. var meta = await db.DonationMeta.AsNoTracking()
  47. .FirstOrDefaultAsync(m => m.ChannelID == channelID.Value, Context.ConnectionAborted);
  48. await Clients.Caller.ReceiveState(new
  49. {
  50. isPaused = meta?.IsPaused ?? false,
  51. isAccepting = meta?.IsAccepting ?? true,
  52. isAudioOnly = meta?.IsAudioOnly ?? false,
  53. isVideoOnly = meta?.IsVideoOnly ?? false
  54. });
  55. }
  56. /// <summary>채널 그룹 퇴장</summary>
  57. public async Task LeaveChannel(string widgetToken)
  58. {
  59. await Groups.RemoveFromGroupAsync(Context.ConnectionId, widgetToken);
  60. }
  61. /// <summary>OBS 위젯에서 재생 시작 보고 (큐에서 꺼내 재생 시작 시점)
  62. /// displayDurationSec — 위젯이 매칭된 AlertConfig 의 노출 시간(초). 리모콘 타이머 ring 표시용.
  63. /// </summary>
  64. public async Task AlertDelivered(int alertID, double? displayDurationSec = null)
  65. {
  66. var alert = await db.DonationAlert
  67. .Include(a => a.Donation)
  68. .FirstOrDefaultAsync(a => a.ID == alertID, Context.ConnectionAborted);
  69. if (alert is null || alert.Donation is null)
  70. {
  71. logger.LogWarning("[DonationHub] AlertDelivered: alert {AlertID} not found", alertID);
  72. return;
  73. }
  74. try
  75. {
  76. alert.MarkPlaying();
  77. await db.SaveChangesAsync(Context.ConnectionAborted);
  78. }
  79. catch (Exception ex)
  80. {
  81. logger.LogWarning(ex, "[DonationHub] AlertDelivered: MarkPlaying failed for {AlertID}", alertID);
  82. return;
  83. }
  84. var token = await db.Channel.AsNoTracking()
  85. .Where(c => c.ID == alert.Donation.ChannelID)
  86. .Select(c => c.WidgetToken)
  87. .FirstOrDefaultAsync(Context.ConnectionAborted);
  88. if (!string.IsNullOrEmpty(token))
  89. {
  90. await Clients.Group(token).ReceiveAlertStatusUpdate(new
  91. {
  92. alertID,
  93. status = "playing",
  94. displayDurationSec,
  95. startedAt = DateTime.UtcNow
  96. });
  97. }
  98. logger.LogInformation("[DonationHub] AlertDelivered → Playing: {AlertID} (duration={Duration}s)", alertID, displayDurationSec);
  99. }
  100. /// <summary>OBS 위젯에서 재생 완료 보고</summary>
  101. public async Task AlertCompleted(int alertID)
  102. {
  103. var alert = await db.DonationAlert
  104. .Include(a => a.Donation)
  105. .FirstOrDefaultAsync(a => a.ID == alertID, Context.ConnectionAborted);
  106. if (alert is null || alert.Donation is null)
  107. {
  108. logger.LogWarning("[DonationHub] AlertCompleted: alert {AlertID} not found", alertID);
  109. return;
  110. }
  111. try
  112. {
  113. alert.MarkDelivered();
  114. await db.SaveChangesAsync(Context.ConnectionAborted);
  115. }
  116. catch (Exception ex)
  117. {
  118. logger.LogWarning(ex, "[DonationHub] AlertCompleted: MarkDelivered failed for {AlertID}", alertID);
  119. return;
  120. }
  121. var token = await db.Channel.AsNoTracking()
  122. .Where(c => c.ID == alert.Donation.ChannelID)
  123. .Select(c => c.WidgetToken)
  124. .FirstOrDefaultAsync(Context.ConnectionAborted);
  125. if (!string.IsNullOrEmpty(token))
  126. {
  127. await Clients.Group(token).ReceiveAlertStatusUpdate(new { alertID, status = "delivered" });
  128. // ⭐ 알림 재생 종료 시점 — Goal/Rank/Crew 위젯에 통계 반영 broadcast
  129. logger.LogInformation("[DonationHub] AlertCompleted broadcast trigger — alertID={AlertID}, channelID={ChannelID}, crewMemberID={CrewMemberID}, isTest={IsTest}",
  130. alertID, alert.Donation.ChannelID, alert.Donation.CrewMemberID, alert.Donation.IsTest);
  131. // 현재 후원 정보 (IsTest=true이면 helper가 영구 합산에 단일 추가, false면 이미 영구에 포함됨)
  132. var currentDonation = new CurrentDonation(
  133. Amount: alert.Donation.Amount,
  134. NetAmount: alert.Donation.NetAmount,
  135. SponsorMemberID: alert.Donation.SponsorMemberID,
  136. SendName: alert.Donation.SendName,
  137. CrewMemberID: alert.Donation.CrewMemberID,
  138. IsTest: alert.Donation.IsTest
  139. );
  140. await DonationBroadcastHelper.BroadcastGoalAndRankAsync(
  141. hubContext, db,
  142. alert.Donation.ChannelID, token, alert.Donation.CrewMemberID,
  143. currentDonation,
  144. Context.ConnectionAborted,
  145. logger
  146. );
  147. }
  148. logger.LogInformation("[DonationHub] AlertCompleted → Delivered: {AlertID}", alertID);
  149. }
  150. /// <summary>OBS 위젯에서 알림 전달 실패 보고</summary>
  151. public async Task AlertFailed(int alertID, string reason)
  152. {
  153. logger.LogWarning("[DonationHub] AlertFailed: {AlertID} — {Reason}", alertID, reason);
  154. var alert = await db.DonationAlert
  155. .Include(a => a.Donation)
  156. .FirstOrDefaultAsync(a => a.ID == alertID, Context.ConnectionAborted);
  157. if (alert is null || alert.Donation is null)
  158. {
  159. return;
  160. }
  161. try
  162. {
  163. alert.MarkFailed();
  164. await db.SaveChangesAsync(Context.ConnectionAborted);
  165. }
  166. catch (Exception ex)
  167. {
  168. logger.LogWarning(ex, "[DonationHub] AlertFailed: MarkFailed save failed for {AlertID}", alertID);
  169. return;
  170. }
  171. var token = await db.Channel.AsNoTracking()
  172. .Where(c => c.ID == alert.Donation.ChannelID)
  173. .Select(c => c.WidgetToken)
  174. .FirstOrDefaultAsync(Context.ConnectionAborted);
  175. if (!string.IsNullOrEmpty(token))
  176. {
  177. await Clients.Group(token).ReceiveAlertStatusUpdate(new { alertID, status = "failed" });
  178. }
  179. }
  180. }