using Application.Abstractions.Cache; using Application.Abstractions.Chat; using Application.Abstractions.Data; using Application.Helpers; using Domain.Entities.Members.Logs; using SharedKernel.Extensions; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.JsonWebTokens; using System.Collections.Concurrent; namespace Web.Api.Hubs; public sealed class ChatHub(IChatMessageStore messageStore, IChatConnectionTracker tracker, IServiceScopeFactory scopeFactory, IChatLeaderboard leaderboard) : Hub { private static readonly ConcurrentDictionary _lastMessageTime = new(); public override async Task OnConnectedAsync() { // 접속 직후에는 어느 채널에 속할지 모름 → 클라이언트가 JoinChannel 명시 호출 대기 // 사용자 메타 정보만 Context.Items 에 준비 var ip = Context.GetHttpContext()?.GetClientIP() ?? "Unknown"; var ua = Context.GetHttpContext()?.GetUserAgent() ?? "Unknown"; ConnectedUser user; if (Context.User?.Identity?.IsAuthenticated == true) { var memberID = GetMemberID(); var memberName = GetMemberName(); string? email = null; if (memberID.HasValue) { using (var scope = scopeFactory.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); email = await db.Member.AsNoTracking().Where(x => x.ID == memberID.Value).Select(x => x.Email).FirstOrDefaultAsync(); } } user = new ConnectedUser(Context.ConnectionId, memberID, email, memberName, ip, ua, false, DateTime.UtcNow); } else { user = new ConnectedUser(Context.ConnectionId, null, null, null, ip, ua, true, DateTime.UtcNow); } Context.Items["user"] = user; await base.OnConnectedAsync(); } public override async Task OnDisconnectedAsync(Exception? exception) { _lastMessageTime.TryRemove(Context.ConnectionId, out _); if (Context.Items["channelSID"] is string channelSID && !string.IsNullOrEmpty(channelSID)) { await tracker.RemoveAsync(channelSID, Context.ConnectionId); if (Context.Items["user"] is ConnectedUser user && !user.IsGuest) { await Clients.OthersInGroup(ChannelGroup(channelSID)).ReceiveSystemMessage($"{user.MemberName}님이 퇴장했습니다."); } await BroadcastParticipantCountAsync(channelSID); } await base.OnDisconnectedAsync(exception); } /// /// 채널 방 참가 — watch 페이지 진입 시 호출 /// public async Task JoinChannel(string channelSID) { if (string.IsNullOrWhiteSpace(channelSID)) { return; } // 이미 다른 채널에 속해있다면 정리 if (Context.Items["channelSID"] is string previous && !string.IsNullOrEmpty(previous) && previous != channelSID) { await LeaveChannelInternal(previous); } Context.Items["channelSID"] = channelSID; await Groups.AddToGroupAsync(Context.ConnectionId, ChannelGroup(channelSID)); if (Context.Items["user"] is ConnectedUser user) { await tracker.AddAsync(channelSID, user); if (!user.IsGuest && !string.IsNullOrEmpty(user.MemberName)) { await Clients.Caller.Connected($"{user.MemberName}님, 환영합니다."); await Clients.OthersInGroup(ChannelGroup(channelSID)).ReceiveSystemMessage($"{user.MemberName}님이 입장했습니다."); } } // 히스토리 + 참여자 수 초기 전송 var messages = await messageStore.GetRecentMessagesAsync(channelSID, ChatSettings.MaxMessages); await Clients.Caller.ReceiveHistory(messages); await BroadcastParticipantCountAsync(channelSID); } /// /// 채널 방 퇴장 — 페이지 이탈 시 호출 /// public async Task LeaveChannel() { if (Context.Items["channelSID"] is string channelSID && !string.IsNullOrEmpty(channelSID)) { await LeaveChannelInternal(channelSID); } } private async Task LeaveChannelInternal(string channelSID) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, ChannelGroup(channelSID)); await tracker.RemoveAsync(channelSID, Context.ConnectionId); if (Context.Items["user"] is ConnectedUser user && !user.IsGuest) { await Clients.OthersInGroup(ChannelGroup(channelSID)).ReceiveSystemMessage($"{user.MemberName}님이 퇴장했습니다."); } await BroadcastParticipantCountAsync(channelSID); Context.Items["channelSID"] = null; } /// /// 로그아웃 시 호출 (invoke('Logout')) /// public async Task Logout() { await LeaveChannel(); await Clients.Caller.Logout("로그아웃 되었습니다."); } /// /// 채팅 메시지 전송 /// public async Task SendMessage(string content) { if (Context.Items["channelSID"] is not string channelSID || string.IsNullOrEmpty(channelSID)) { return; } if (string.IsNullOrWhiteSpace(content)) { return; } content = content.Trim(); if (content.Length > ChatSettings.MaxContentLength) { return; } var now = DateTime.UtcNow; if (_lastMessageTime.TryGetValue(Context.ConnectionId, out var lastTime)) { if ((now - lastTime).TotalSeconds < ChatSettings.RateLimitSeconds) { return; } } _lastMessageTime[Context.ConnectionId] = now; var memberID = GetMemberID(); if (memberID is null) { return; } ChatMessage message; using (var scope = scopeFactory.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var cache = scope.ServiceProvider.GetRequiredService(); var cfg = await ChatExpConfigLoader.GetAsync(cache, db, default); var channelID = await db.Channel.AsNoTracking().Where(c => c.SID == channelSID).Select(c => (int?)c.ID).FirstOrDefaultAsync(); if (channelID is null) { return; } // 멤버 표시 정보 — Redis 캐시 5분 TTL. 운영자 변경 후 최대 5분 stale 허용 var memberCacheKey = $"member:{memberID.Value}:displayInfo"; var memberData = await cache.GetAsync(memberCacheKey); if (memberData is null) { memberData = await db.Member.AsNoTracking() .Where(x => x.ID == memberID.Value) .Select(x => new MemberDisplayInfo( x.SID, x.Name, x.Icon, x.MemberGrade != null ? x.MemberGrade.Image : null, x.MemberGrade != null ? x.MemberGrade.TextColor : null)) .FirstOrDefaultAsync(); if (memberData is null) { return; } await cache.SetAsync(memberCacheKey, memberData, TimeSpan.FromMinutes(5)); } // 채널 문맥 칭호 (이 채널에서 선택한 대표 칭호, Active 일 때만) — 5분 캐시 // 칭호 미선택은 모든 필드 null 인 sentinel 로 캐시 (DB 재조회 회피) var titleCacheKey = $"member:{memberID.Value}:title:{channelID.Value}"; var titleData = await cache.GetAsync(titleCacheKey); if (titleData is null) { var queried = await db.DonorTitleSelection.AsNoTracking() .Where(d => d.DonorMemberID == memberID.Value && d.ChannelID == channelID.Value && d.SelectedTitleID != null && d.SelectedTitle!.IsActive) .Select(d => new TitleDisplayInfo( d.SelectedTitle!.IconUrl, d.SelectedTitle.Name, d.SelectedTitle.Color)) .FirstOrDefaultAsync(); titleData = queried ?? new TitleDisplayInfo(null, null, null); await cache.SetAsync(titleCacheKey, titleData, TimeSpan.FromMinutes(5)); } // ── XP 적립 (방송 세션 활성 + 최소 글자수 + 세션 상한 미초과) ── int? leaderboardRank = null; int? leaderboardXp = null; if (cfg.ChatXpPerMessage > 0 && content.Length >= cfg.MinContentLength) { var session = await db.BroadcastSession.Where(s => s.ChannelID == channelID.Value && !s.IsFinalized).OrderByDescending(s => s.CreatedAt).Select(s => new { s.ID }).FirstOrDefaultAsync(); if (session is not null) { // 본인 세션 누적 XP — 채팅 reason 합 + 전체 합을 한 쿼리로 동시 계산 (이전 두 SUM → 한 GROUP BY) var sums = await db.MemberExpLog.AsNoTracking() .Where(e => e.MemberID == memberID.Value && e.BroadcastSessionID == session.ID) .GroupBy(e => e.Reason == "채팅(라이브)") .Select(g => new { IsChat = g.Key, Sum = g.Sum(x => x.Amount) }) .ToListAsync(); var sessionChatXp = sums.Where(x => x.IsChat).Sum(x => x.Sum); var myXp = sums.Sum(x => x.Sum); if (sessionChatXp < cfg.ChatXpSessionLimit) { var grant = Math.Min(cfg.ChatXpPerMessage, cfg.ChatXpSessionLimit - sessionChatXp); var stats = await db.MemberStats.FirstOrDefaultAsync(s => s.MemberID == memberID.Value); if (stats is not null) { stats.Exp += grant; var logEntry = MemberExpLog.CreateForBroadcast( memberID.Value, "채팅(라이브)", grant, stats.Exp, session.ID, channelID.Value ); db.MemberExpLog.Add(logEntry); await db.SaveChangesAsync(); // grant 반영 후 메모리상 합 갱신 (DB 재조회 회피) myXp += grant; } } // 본인 랭크 계산 (Top N 크라운 여부) — Redis sorted set 캐시 기반 (TTL 5초) // myXp 는 grant 반영된 메모리 값, higherCount 는 캐시 기준 (최대 5초 stale 가능) if (myXp > 0) { var rankResult = await leaderboard.GetRankAsync(session.ID, memberID.Value, db); var higherCount = rankResult?.HigherCount ?? 0; var rank = higherCount + 1; leaderboardXp = myXp; if (rank <= cfg.CrownTopN) { leaderboardRank = rank; } } } } message = new ChatMessage( MemberID: memberID.Value, MemberSID: memberData.SID, MemberName: memberData.Name ?? "익명", Content: content, SentAt: now, TitleIconUrl: titleData.IconUrl, TitleName: titleData.Name, TitleColor: titleData.Color, GradeImageUrl: memberData.GradeImage, GradeTextColor: memberData.GradeTextColor, MemberIcon: memberData.Icon, LeaderboardRank: leaderboardRank, LeaderboardXp: leaderboardXp ); } // 채널별 기록 저장 await messageStore.AddMessageAsync(channelSID, message); // 해당 채널 그룹에만 전송 await Clients.Group(ChannelGroup(channelSID)).ReceiveMessage(message); } /// /// 채팅 기록 요청 /// public async Task RequestHistory() { if (Context.Items["channelSID"] is not string channelSID || string.IsNullOrEmpty(channelSID)) { await Clients.Caller.ReceiveHistory([]); return; } var messages = await messageStore.GetRecentMessagesAsync(channelSID, ChatSettings.MaxMessages); await Clients.Caller.ReceiveHistory(messages); } /// /// 접속자 수 요청 — 현재 참여 중인 채널 기준 /// public async Task RequestParticipantCount() { if (Context.Items["channelSID"] is not string channelSID || string.IsNullOrEmpty(channelSID)) { await Clients.Caller.ReceiveParticipantCount(0); return; } var count = await tracker.GetCountByChannelAsync(channelSID); await Clients.Caller.ReceiveParticipantCount(count); } /// /// 참여자 목록 요청 — 현재 참여 중인 채널 기준 /// public async Task RequestParticipants() { if (Context.Items["channelSID"] is not string channelSID || string.IsNullOrEmpty(channelSID)) { await Clients.Caller.ReceiveParticipants([]); return; } var users = await tracker.GetByChannelAsync(channelSID); var participants = users .Where(u => !u.IsGuest && !string.IsNullOrEmpty(u.MemberName)) .Select(u => new ChatParticipant(u.MemberName!, u.IsGuest)) .DistinctBy(p => p.MemberName) .OrderBy(p => p.MemberName) .ToList(); await Clients.Caller.ReceiveParticipants(participants); } // 채널 내부 접속자 수 브로드캐스트 private async Task BroadcastParticipantCountAsync(string channelSID) { var count = await tracker.GetCountByChannelAsync(channelSID); await Clients.Group(ChannelGroup(channelSID)).ReceiveParticipantCount(count); } private static string ChannelGroup(string channelSID) => $"chat:channel:{channelSID}"; // 회원 ID 조회 private int? GetMemberID() { var sub = Context.User?.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; if (int.TryParse(sub, out var memberID)) { return memberID; } return null; } // 회원 이름 조회 private string? GetMemberName() { return Context.User?.FindFirst(JwtRegisteredClaimNames.Name)?.Value; } } // 채팅 메시지 표시용 캐시 DTO — 파일 한정 (file-scoped) file sealed record MemberDisplayInfo(string SID, string? Name, string? Icon, string? GradeImage, string? GradeTextColor); file sealed record TitleDisplayInfo(string? IconUrl, string? Name, string? Color);