using Application.Abstractions.Data; using Application.Abstractions.Hub; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Infrastructure.Hubs; /// /// 후원 전용 Hub — OBS 위젯, 리모콘 전용 /// 채널 WidgetToken 기반 그룹으로 분리 /// public sealed class DonationHub( IAppDbContext db, ILogger logger, IHubContext hubContext ) : Hub { 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); } /// 채널 그룹 참가 (OBS 위젯, 리모콘에서 호출) — WidgetToken 유효성 검증 후에만 참가 허용 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 }); } /// 채널 그룹 퇴장 public async Task LeaveChannel(string widgetToken) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, widgetToken); } /// OBS 위젯에서 재생 시작 보고 (큐에서 꺼내 재생 시작 시점) /// displayDurationSec — 위젯이 매칭된 AlertConfig 의 노출 시간(초). 리모콘 타이머 ring 표시용. /// 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); } /// OBS 위젯에서 재생 완료 보고 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); } /// OBS 위젯에서 알림 전달 실패 보고 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" }); } } }