| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210 |
- using Application.Abstractions.Data;
- using Application.Abstractions.Hub;
- using Microsoft.AspNetCore.SignalR;
- using Microsoft.EntityFrameworkCore;
- using Microsoft.Extensions.Logging;
- namespace Infrastructure.Hubs;
- /// <summary>
- /// 후원 전용 Hub — OBS 위젯, 리모콘 전용
- /// 채널 WidgetToken 기반 그룹으로 분리
- /// </summary>
- public sealed class DonationHub(
- IAppDbContext db,
- ILogger<DonationHub> logger,
- IHubContext<DonationHub, IDonationHubClient> hubContext
- ) : Hub<IDonationHubClient>
- {
- public override async Task OnConnectedAsync()
- {
- logger.LogInformation("[DonationHub] Connected: {ConnectionId}", Context.ConnectionId);
- await base.OnConnectedAsync();
- }
- public override async Task OnDisconnectedAsync(Exception? exception)
- {
- logger.LogInformation("[DonationHub] Disconnected: {ConnectionId}", Context.ConnectionId);
- await base.OnDisconnectedAsync(exception);
- }
- /// <summary>채널 그룹 참가 (OBS 위젯, 리모콘에서 호출) — WidgetToken 유효성 검증 후에만 참가 허용</summary>
- public async Task JoinChannel(string widgetToken)
- {
- if (string.IsNullOrWhiteSpace(widgetToken))
- {
- logger.LogWarning("[DonationHub] Empty widgetToken rejected from {ConnectionId}", Context.ConnectionId);
- Context.Abort();
- return;
- }
- var channelID = await db.Channel.AsNoTracking().Where(c => c.WidgetToken == widgetToken && c.IsActive).Select(c => (int?)c.ID).FirstOrDefaultAsync(Context.ConnectionAborted);
- if (channelID is null)
- {
- logger.LogWarning("[DonationHub] Invalid widgetToken rejected from {ConnectionId}", Context.ConnectionId);
- Context.Abort();
- return;
- }
- await Groups.AddToGroupAsync(Context.ConnectionId, widgetToken);
- logger.LogInformation("[DonationHub] {ConnectionId} joined channel {ChannelID}", Context.ConnectionId, channelID);
- // 새로 join한 connection에 현재 메타 상태(일시정지/수신/음성/영상) 즉시 전송
- var meta = await db.DonationMeta.AsNoTracking()
- .FirstOrDefaultAsync(m => m.ChannelID == channelID.Value, Context.ConnectionAborted);
- await Clients.Caller.ReceiveState(new
- {
- isPaused = meta?.IsPaused ?? false,
- isAccepting = meta?.IsAccepting ?? true,
- isAudioOnly = meta?.IsAudioOnly ?? false,
- isVideoOnly = meta?.IsVideoOnly ?? false
- });
- }
- /// <summary>채널 그룹 퇴장</summary>
- public async Task LeaveChannel(string widgetToken)
- {
- await Groups.RemoveFromGroupAsync(Context.ConnectionId, widgetToken);
- }
- /// <summary>OBS 위젯에서 재생 시작 보고 (큐에서 꺼내 재생 시작 시점)
- /// displayDurationSec — 위젯이 매칭된 AlertConfig 의 노출 시간(초). 리모콘 타이머 ring 표시용.
- /// </summary>
- public async Task AlertDelivered(int alertID, double? displayDurationSec = null)
- {
- var alert = await db.DonationAlert
- .Include(a => a.Donation)
- .FirstOrDefaultAsync(a => a.ID == alertID, Context.ConnectionAborted);
- if (alert is null || alert.Donation is null)
- {
- logger.LogWarning("[DonationHub] AlertDelivered: alert {AlertID} not found", alertID);
- return;
- }
- try
- {
- alert.MarkPlaying();
- await db.SaveChangesAsync(Context.ConnectionAborted);
- }
- catch (Exception ex)
- {
- logger.LogWarning(ex, "[DonationHub] AlertDelivered: MarkPlaying failed for {AlertID}", alertID);
- return;
- }
- var token = await db.Channel.AsNoTracking()
- .Where(c => c.ID == alert.Donation.ChannelID)
- .Select(c => c.WidgetToken)
- .FirstOrDefaultAsync(Context.ConnectionAborted);
- if (!string.IsNullOrEmpty(token))
- {
- await Clients.Group(token).ReceiveAlertStatusUpdate(new
- {
- alertID,
- status = "playing",
- displayDurationSec,
- startedAt = DateTime.UtcNow
- });
- }
- logger.LogInformation("[DonationHub] AlertDelivered → Playing: {AlertID} (duration={Duration}s)", alertID, displayDurationSec);
- }
- /// <summary>OBS 위젯에서 재생 완료 보고</summary>
- public async Task AlertCompleted(int alertID)
- {
- var alert = await db.DonationAlert
- .Include(a => a.Donation)
- .FirstOrDefaultAsync(a => a.ID == alertID, Context.ConnectionAborted);
- if (alert is null || alert.Donation is null)
- {
- logger.LogWarning("[DonationHub] AlertCompleted: alert {AlertID} not found", alertID);
- return;
- }
- try
- {
- alert.MarkDelivered();
- await db.SaveChangesAsync(Context.ConnectionAborted);
- }
- catch (Exception ex)
- {
- logger.LogWarning(ex, "[DonationHub] AlertCompleted: MarkDelivered failed for {AlertID}", alertID);
- return;
- }
- var token = await db.Channel.AsNoTracking()
- .Where(c => c.ID == alert.Donation.ChannelID)
- .Select(c => c.WidgetToken)
- .FirstOrDefaultAsync(Context.ConnectionAborted);
- if (!string.IsNullOrEmpty(token))
- {
- await Clients.Group(token).ReceiveAlertStatusUpdate(new { alertID, status = "delivered" });
- // ⭐ 알림 재생 종료 시점 — Goal/Rank/Crew 위젯에 통계 반영 broadcast
- logger.LogInformation("[DonationHub] AlertCompleted broadcast trigger — alertID={AlertID}, channelID={ChannelID}, crewMemberID={CrewMemberID}, isTest={IsTest}",
- alertID, alert.Donation.ChannelID, alert.Donation.CrewMemberID, alert.Donation.IsTest);
- // 현재 후원 정보 (IsTest=true이면 helper가 영구 합산에 단일 추가, false면 이미 영구에 포함됨)
- var currentDonation = new CurrentDonation(
- Amount: alert.Donation.Amount,
- NetAmount: alert.Donation.NetAmount,
- SponsorMemberID: alert.Donation.SponsorMemberID,
- SendName: alert.Donation.SendName,
- CrewMemberID: alert.Donation.CrewMemberID,
- IsTest: alert.Donation.IsTest
- );
- await DonationBroadcastHelper.BroadcastGoalAndRankAsync(
- hubContext, db,
- alert.Donation.ChannelID, token, alert.Donation.CrewMemberID,
- currentDonation,
- Context.ConnectionAborted,
- logger
- );
- }
- logger.LogInformation("[DonationHub] AlertCompleted → Delivered: {AlertID}", alertID);
- }
- /// <summary>OBS 위젯에서 알림 전달 실패 보고</summary>
- public async Task AlertFailed(int alertID, string reason)
- {
- logger.LogWarning("[DonationHub] AlertFailed: {AlertID} — {Reason}", alertID, reason);
- var alert = await db.DonationAlert
- .Include(a => a.Donation)
- .FirstOrDefaultAsync(a => a.ID == alertID, Context.ConnectionAborted);
- if (alert is null || alert.Donation is null)
- {
- return;
- }
- try
- {
- alert.MarkFailed();
- await db.SaveChangesAsync(Context.ConnectionAborted);
- }
- catch (Exception ex)
- {
- logger.LogWarning(ex, "[DonationHub] AlertFailed: MarkFailed save failed for {AlertID}", alertID);
- return;
- }
- var token = await db.Channel.AsNoTracking()
- .Where(c => c.ID == alert.Donation.ChannelID)
- .Select(c => c.WidgetToken)
- .FirstOrDefaultAsync(Context.ConnectionAborted);
- if (!string.IsNullOrEmpty(token))
- {
- await Clients.Group(token).ReceiveAlertStatusUpdate(new { alertID, status = "failed" });
- }
- }
- }
|