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}";
}