AppHub.cs 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. using Application.Abstractions.Cache;
  2. using Application.Abstractions.Chat;
  3. using Application.Abstractions.Data;
  4. using Application.Abstractions.Hub;
  5. using Microsoft.AspNetCore.SignalR;
  6. using Microsoft.EntityFrameworkCore;
  7. using Microsoft.Extensions.DependencyInjection;
  8. using Microsoft.IdentityModel.JsonWebTokens;
  9. using SharedKernel.Extensions;
  10. using StackExchange.Redis;
  11. namespace Infrastructure.Hubs;
  12. /// <summary>
  13. /// 공통 Hub — 모든 페이지에서 사용
  14. /// 접속자 추적 + 채팅 + 알림 + 쪽지 + 크루 초대/동의/toast + YouTube 채팅 통합
  15. /// </summary>
  16. public sealed class AppHub(
  17. IConnectionMultiplexer redis,
  18. IPresenceTracker presence,
  19. IVisitorTracker visitors,
  20. IServiceScopeFactory scopeFactory
  21. ) : Hub<IAppHubClient>
  22. {
  23. // ── 연결 관리 ────────────────────────────────────────────────
  24. public override async Task OnConnectedAsync()
  25. {
  26. var memberID = GetMemberID();
  27. var httpContext = Context.GetHttpContext();
  28. var ip = httpContext?.GetClientIP() ?? "Unknown";
  29. var ua = httpContext?.GetUserAgent() ?? "Unknown";
  30. if (memberID > 0)
  31. {
  32. var db = redis.GetDatabase();
  33. await db.HashSetAsync(CacheKeys.ChatConnections, Context.ConnectionId, memberID);
  34. // Online presence (refcount). 0→1 전환이면 구독자에게 broadcast.
  35. var wasFirst = await presence.AddConnectionAsync(memberID, Context.ConnectionId);
  36. // 개인 그룹 (알림/쪽지용)
  37. await Groups.AddToGroupAsync(Context.ConnectionId, $"member:{memberID}");
  38. if (wasFirst)
  39. {
  40. await Clients.Group(PresenceGroup(memberID)).ReceivePresenceChange(memberID, true);
  41. }
  42. // 전역 접속자 기록 (Admin 현재 접속자 페이지용)
  43. var memberName = Context.User?.FindFirst(JwtRegisteredClaimNames.Name)?.Value;
  44. string? email = null;
  45. using (var scope = scopeFactory.CreateScope())
  46. {
  47. var appDb = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  48. email = await appDb.Member.AsNoTracking().Where(x => x.ID == memberID).Select(x => x.Email).FirstOrDefaultAsync();
  49. }
  50. await visitors.AddAsync(new ConnectedUser(Context.ConnectionId, memberID, email, memberName, ip, ua, false, DateTime.UtcNow));
  51. }
  52. else
  53. {
  54. // 비로그인 게스트도 전역 접속자에 기록
  55. await visitors.AddAsync(new ConnectedUser(Context.ConnectionId, null, null, null, ip, ua, true, DateTime.UtcNow));
  56. }
  57. await base.OnConnectedAsync();
  58. }
  59. public override async Task OnDisconnectedAsync(Exception? exception)
  60. {
  61. // 전역 접속자 제거 (Admin 현재 접속자 페이지용)
  62. await visitors.RemoveAsync(Context.ConnectionId);
  63. var db = redis.GetDatabase();
  64. var memberIDValue = await db.HashGetAsync(CacheKeys.ChatConnections, Context.ConnectionId);
  65. await db.HashDeleteAsync(CacheKeys.ChatConnections, Context.ConnectionId);
  66. if (memberIDValue.HasValue && int.TryParse(memberIDValue.ToString(), out var memberID) && memberID > 0)
  67. {
  68. var wasLast = await presence.RemoveConnectionAsync(memberID, Context.ConnectionId);
  69. if (wasLast)
  70. {
  71. await Clients.Group(PresenceGroup(memberID)).ReceivePresenceChange(memberID, false);
  72. }
  73. }
  74. await base.OnDisconnectedAsync(exception);
  75. }
  76. // ── 채팅 그룹 참가/퇴장 ──────────────────────────────────────
  77. public async Task JoinChat(string room)
  78. {
  79. await Groups.AddToGroupAsync(Context.ConnectionId, $"chat:{room}");
  80. }
  81. public async Task LeaveChat(string room)
  82. {
  83. await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"chat:{room}");
  84. }
  85. // ── 채팅 메시지 전송 ─────────────────────────────────────────
  86. public async Task SendMessage(string room, ChatMessage message)
  87. {
  88. await Clients.Group($"chat:{room}").ReceiveMessage(message);
  89. }
  90. // ── 채널 그룹 (크루 초대/동의 실시간 등) ─────────────────────
  91. public async Task JoinChannel(string channelSID)
  92. {
  93. await Groups.AddToGroupAsync(Context.ConnectionId, $"channel:{channelSID}");
  94. }
  95. public async Task LeaveChannel(string channelSID)
  96. {
  97. await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"channel:{channelSID}");
  98. }
  99. // ── Presence 구독 (특정 멤버들의 online 변화 수신) ───────────
  100. /// <summary>여러 멤버의 presence 변화 수신을 구독.</summary>
  101. public async Task SubscribePresence(int[] memberIDs)
  102. {
  103. if (memberIDs is null || memberIDs.Length == 0)
  104. {
  105. return;
  106. }
  107. foreach (var id in memberIDs.Distinct())
  108. {
  109. if (id > 0)
  110. {
  111. await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(id));
  112. }
  113. }
  114. }
  115. /// <summary>구독 해제.</summary>
  116. public async Task UnsubscribePresence(int[] memberIDs)
  117. {
  118. if (memberIDs is null || memberIDs.Length == 0)
  119. {
  120. return;
  121. }
  122. foreach (var id in memberIDs.Distinct())
  123. {
  124. if (id > 0)
  125. {
  126. await Groups.RemoveFromGroupAsync(Context.ConnectionId, PresenceGroup(id));
  127. }
  128. }
  129. }
  130. // ── Private ──────────────────────────────────────────────────
  131. private int GetMemberID()
  132. {
  133. // JWT sub 클레임 (Infrastructure/DependencyInjection.cs 에서 MapInboundClaims=false 이므로 그대로 "sub" 이름).
  134. var claim = Context.User?.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
  135. return int.TryParse(claim, out var id) ? id : 0;
  136. }
  137. private static string PresenceGroup(int memberID) => $"presence:{memberID}";
  138. }