| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196 |
- 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;
- /// <summary>
- /// 공통 Hub — 모든 페이지에서 사용
- /// 접속자 추적 + 채팅 + 알림 + 쪽지 + 크루 초대/동의/toast + YouTube 채팅 통합
- /// </summary>
- public sealed class AppHub(
- IConnectionMultiplexer redis,
- IPresenceTracker presence,
- IVisitorTracker visitors,
- IServiceScopeFactory scopeFactory
- ) : Hub<IAppHubClient>
- {
- // ── 연결 관리 ────────────────────────────────────────────────
- 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<IAppDbContext>();
- 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}");
- }
- // ── 크루 운영자 그룹 (크루원 리스트 실시간 갱신용) ──────────
- /// <summary>크루 관리 페이지(운영자) 진입 시 호출. 멤버 변경 이벤트 수신 시작.</summary>
- public async Task JoinCrew(int crewID)
- {
- if (crewID <= 0)
- {
- return;
- }
- await Groups.AddToGroupAsync(Context.ConnectionId, CrewGroup(crewID));
- }
- /// <summary>크루 관리 페이지 이탈 시 호출.</summary>
- public async Task LeaveCrew(int crewID)
- {
- if (crewID <= 0)
- {
- return;
- }
- await Groups.RemoveFromGroupAsync(Context.ConnectionId, CrewGroup(crewID));
- }
- // ── Presence 구독 (특정 멤버들의 online 변화 수신) ───────────
- /// <summary>여러 멤버의 presence 변화 수신을 구독.</summary>
- 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));
- }
- }
- }
- /// <summary>구독 해제.</summary>
- 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}";
- private static string CrewGroup(int crewID) => $"crew:{crewID}";
- }
|