using Application.Abstractions.Cache; using Application.Abstractions.Chat; using Application.Abstractions.Data; using Application.Abstractions.Hub; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.JsonWebTokens; using SharedKernel.Extensions; using StackExchange.Redis; namespace Infrastructure.Hubs; /// /// 공통 Hub — 모든 페이지에서 사용 /// 접속자 추적 + 채팅 + 알림 + 쪽지 + 크루 초대/동의/toast + YouTube 채팅 통합 /// public sealed class AppHub( IConnectionMultiplexer redis, IPresenceTracker presence, IVisitorTracker visitors, IServiceScopeFactory scopeFactory ) : Hub { // ── 연결 관리 ──────────────────────────────────────────────── public override async Task OnConnectedAsync() { var memberID = GetMemberID(); var httpContext = Context.GetHttpContext(); var ip = httpContext?.GetClientIP() ?? "Unknown"; var ua = httpContext?.GetUserAgent() ?? "Unknown"; if (memberID > 0) { var db = redis.GetDatabase(); await db.HashSetAsync(CacheKeys.ChatConnections, Context.ConnectionId, memberID); // Online presence (refcount). 0→1 전환이면 구독자에게 broadcast. var wasFirst = await presence.AddConnectionAsync(memberID, Context.ConnectionId); // 개인 그룹 (알림/쪽지용) await Groups.AddToGroupAsync(Context.ConnectionId, $"member:{memberID}"); if (wasFirst) { await Clients.Group(PresenceGroup(memberID)).ReceivePresenceChange(memberID, true); } // 전역 접속자 기록 (Admin 현재 접속자 페이지용) var memberName = Context.User?.FindFirst(JwtRegisteredClaimNames.Name)?.Value; string? email = null; using (var scope = scopeFactory.CreateScope()) { var appDb = scope.ServiceProvider.GetRequiredService(); email = await appDb.Member.AsNoTracking().Where(x => x.ID == memberID).Select(x => x.Email).FirstOrDefaultAsync(); } await visitors.AddAsync(new ConnectedUser(Context.ConnectionId, memberID, email, memberName, ip, ua, false, DateTime.UtcNow)); } else { // 비로그인 게스트도 전역 접속자에 기록 await visitors.AddAsync(new ConnectedUser(Context.ConnectionId, null, null, null, ip, ua, true, DateTime.UtcNow)); } await base.OnConnectedAsync(); } public override async Task OnDisconnectedAsync(Exception? exception) { // 전역 접속자 제거 (Admin 현재 접속자 페이지용) await visitors.RemoveAsync(Context.ConnectionId); var db = redis.GetDatabase(); var memberIDValue = await db.HashGetAsync(CacheKeys.ChatConnections, Context.ConnectionId); await db.HashDeleteAsync(CacheKeys.ChatConnections, Context.ConnectionId); if (memberIDValue.HasValue && int.TryParse(memberIDValue.ToString(), out var memberID) && memberID > 0) { var wasLast = await presence.RemoveConnectionAsync(memberID, Context.ConnectionId); if (wasLast) { await Clients.Group(PresenceGroup(memberID)).ReceivePresenceChange(memberID, false); } } await base.OnDisconnectedAsync(exception); } // ── 채팅 그룹 참가/퇴장 ────────────────────────────────────── public async Task JoinChat(string room) { await Groups.AddToGroupAsync(Context.ConnectionId, $"chat:{room}"); } public async Task LeaveChat(string room) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"chat:{room}"); } // ── 채팅 메시지 전송 ───────────────────────────────────────── public async Task SendMessage(string room, ChatMessage message) { await Clients.Group($"chat:{room}").ReceiveMessage(message); } // ── 채널 그룹 (크루 초대/동의 실시간 등) ───────────────────── public async Task JoinChannel(string channelSID) { await Groups.AddToGroupAsync(Context.ConnectionId, $"channel:{channelSID}"); } public async Task LeaveChannel(string channelSID) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"channel:{channelSID}"); } // ── Presence 구독 (특정 멤버들의 online 변화 수신) ─────────── /// 여러 멤버의 presence 변화 수신을 구독. public async Task SubscribePresence(int[] memberIDs) { if (memberIDs is null || memberIDs.Length == 0) { return; } foreach (var id in memberIDs.Distinct()) { if (id > 0) { await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(id)); } } } /// 구독 해제. public async Task UnsubscribePresence(int[] memberIDs) { if (memberIDs is null || memberIDs.Length == 0) { return; } foreach (var id in memberIDs.Distinct()) { if (id > 0) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, PresenceGroup(id)); } } } // ── Private ────────────────────────────────────────────────── private int GetMemberID() { // JWT sub 클레임 (Infrastructure/DependencyInjection.cs 에서 MapInboundClaims=false 이므로 그대로 "sub" 이름). var claim = Context.User?.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; return int.TryParse(claim, out var id) ? id : 0; } private static string PresenceGroup(int memberID) => $"presence:{memberID}"; }