using Application.Abstractions.Cache; using Application.Abstractions.Chat; using Application.Abstractions.Data; using Application.Helpers; using SharedKernel.Extensions; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.JsonWebTokens; using System.Collections.Concurrent; namespace Web.Api.Hubs; /// /// 룸 기반 실시간 채팅 허브 (개미투자 D2 M1) — roomKey = main | stock:{code}. /// 게스트는 수신/히스토리 열람 가능, 전송(SendMessage)은 인증 필수. /// XP 지급은 IChatRewardHook 으로 위임 (M1 no-op, D3 실구현 — d0 §2.2). /// public sealed class ChatHub(IChatMessageStore messageStore, IChatConnectionTracker tracker, IServiceScopeFactory scopeFactory, IChatRewardHook rewardHook) : Hub { private static readonly ConcurrentDictionary _lastMessageTime = new(); public override async Task OnConnectedAsync() { // 접속 직후에는 어느 룸에 속할지 모름 → 클라이언트가 JoinRoom 명시 호출 대기 // 사용자 메타 정보만 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["roomKey"] is string roomKey && !string.IsNullOrEmpty(roomKey)) { await tracker.RemoveAsync(roomKey, Context.ConnectionId); if (Context.Items["user"] is ConnectedUser user && !user.IsGuest && ChatRoomKeys.IsChannelRoom(roomKey)) { await Clients.OthersInGroup(RoomGroup(roomKey)).ReceiveSystemMessage($"{user.MemberName}님이 퇴장했습니다."); } await BroadcastParticipantCountAsync(roomKey); } await base.OnDisconnectedAsync(exception); } /// /// 룸 참가 — roomKey: main | stock:{code}. 게스트도 참가(읽기) 가능. /// 종목방은 ChatRoomConfig row 존재 + IsActive 일 때만 오픈 (단계 오픈 — d0 §5-④). /// public async Task JoinRoom(string roomKey) { roomKey = roomKey?.Trim() ?? string.Empty; if (!ChatRoomKeys.IsValid(roomKey)) { await Clients.Caller.ReceiveSystemMessage("존재하지 않는 채팅방입니다."); return; } using (var scope = scopeFactory.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var cache = scope.ServiceProvider.GetRequiredService(); var config = await ChatRoomConfigLoader.GetAsync(cache, db, roomKey, default); if (!ChatRoomConfigLoader.IsRoomOpen(roomKey, config)) { await Clients.Caller.ReceiveSystemMessage("아직 열리지 않은 채팅방입니다."); return; } } await JoinRoomInternal(roomKey); } /// /// 룸 퇴장 — 페이지 이탈 시 호출 /// public async Task LeaveRoom() { if (Context.Items["roomKey"] is string roomKey && !string.IsNullOrEmpty(roomKey)) { await LeaveRoomInternal(roomKey); } } /// /// [레거시 어댑터] 채널 방 참가 — 기존 watch 페이지 호환용 (Features:Channel 비활성 상태 코드 보존). /// 내부적으로 channel:{sid} 룸으로 매핑되며 신규 클라이언트는 JoinRoom 을 사용할 것. /// public async Task JoinChannel(string channelSID) { if (string.IsNullOrWhiteSpace(channelSID)) { return; } await JoinRoomInternal(ChatRoomKeys.ForChannel(channelSID.Trim())); } /// /// [레거시 어댑터] 채널 방 퇴장 — LeaveRoom 과 동일 /// public async Task LeaveChannel() { await LeaveRoom(); } private async Task JoinRoomInternal(string roomKey) { // 이미 다른 룸에 속해있다면 정리 if (Context.Items["roomKey"] is string previous && !string.IsNullOrEmpty(previous) && previous != roomKey) { await LeaveRoomInternal(previous); } Context.Items["roomKey"] = roomKey; await Groups.AddToGroupAsync(Context.ConnectionId, RoomGroup(roomKey)); if (Context.Items["user"] is ConnectedUser user) { await tracker.AddAsync(roomKey, user); if (!user.IsGuest && !string.IsNullOrEmpty(user.MemberName)) { await Clients.Caller.Connected($"{user.MemberName}님, 환영합니다."); // 입장 브로드캐스트는 소규모 채널 룸만 — main/종목방은 인원 규모상 스팸 방지 목적으로 생략 if (ChatRoomKeys.IsChannelRoom(roomKey)) { await Clients.OthersInGroup(RoomGroup(roomKey)).ReceiveSystemMessage($"{user.MemberName}님이 입장했습니다."); } } } // 히스토리 + 참여자 수 초기 전송 var messages = await messageStore.GetRecentMessagesAsync(roomKey, ChatSettings.MaxMessages); await Clients.Caller.ReceiveHistory(messages); await BroadcastParticipantCountAsync(roomKey); } private async Task LeaveRoomInternal(string roomKey) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, RoomGroup(roomKey)); await tracker.RemoveAsync(roomKey, Context.ConnectionId); if (Context.Items["user"] is ConnectedUser user && !user.IsGuest && ChatRoomKeys.IsChannelRoom(roomKey)) { await Clients.OthersInGroup(RoomGroup(roomKey)).ReceiveSystemMessage($"{user.MemberName}님이 퇴장했습니다."); } await BroadcastParticipantCountAsync(roomKey); Context.Items["roomKey"] = null; } /// /// 로그아웃 시 호출 (invoke('Logout')) /// public async Task Logout() { await LeaveRoom(); await Clients.Caller.Logout("로그아웃 되었습니다."); } /// /// 채팅 메시지 전송 — 인증 필수 (게스트는 읽기 전용). /// 정책: 길이 제한 + rate limit + 룸 SlowMode + ChatBan 검사. /// public async Task SendMessage(string content) { if (Context.Items["roomKey"] is not string roomKey || string.IsNullOrEmpty(roomKey)) { return; } if (string.IsNullOrWhiteSpace(content)) { return; } content = content.Trim(); if (content.Length > ChatSettings.MaxContentLength) { return; } var memberID = GetMemberID(); if (memberID is null) { return; } var now = DateTime.UtcNow; ChatMessage message; using (var scope = scopeFactory.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var cache = scope.ServiceProvider.GetRequiredService(); // rate limit — 전역 최소 간격 + 룸별 SlowMode 중 큰 값 var config = await ChatRoomConfigLoader.GetAsync(cache, db, roomKey, default); var minInterval = Math.Max(ChatSettings.RateLimitSeconds, (int)config.SlowModeSeconds); if (_lastMessageTime.TryGetValue(Context.ConnectionId, out var lastTime)) { if ((now - lastTime).TotalSeconds < minInterval) { return; } } // 채팅 제재 (뮤트/밴) — 룸 지정 또는 전체 제재 if (await ChatBanChecker.IsBannedAsync(db, memberID.Value, roomKey, now, default)) { await Clients.Caller.ReceiveSystemMessage("채팅이 제한된 상태입니다."); return; } _lastMessageTime[Context.ConnectionId] = now; // 멤버 표시 정보 — 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)); } var titleData = new TitleDisplayInfo(null, null, null); 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 ); } // 룸별 기록 저장 await messageStore.AddMessageAsync(roomKey, message); // 해당 룸 그룹에만 전송 await Clients.Group(RoomGroup(roomKey)).ReceiveMessage(message); // XP/포인트 지급 훅 (M1 no-op, D3 실구현) — 실패해도 채팅 흐름에 영향 없어야 함 await rewardHook.OnMessageSentAsync(memberID.Value, roomKey, content); } /// /// 채팅 기록 요청 /// public async Task RequestHistory() { if (Context.Items["roomKey"] is not string roomKey || string.IsNullOrEmpty(roomKey)) { await Clients.Caller.ReceiveHistory([]); return; } var messages = await messageStore.GetRecentMessagesAsync(roomKey, ChatSettings.MaxMessages); await Clients.Caller.ReceiveHistory(messages); } /// /// 접속자 수 요청 — 현재 참여 중인 룸 기준 /// public async Task RequestParticipantCount() { if (Context.Items["roomKey"] is not string roomKey || string.IsNullOrEmpty(roomKey)) { await Clients.Caller.ReceiveParticipantCount(0); return; } var count = await tracker.GetCountByRoomAsync(roomKey); await Clients.Caller.ReceiveParticipantCount(count); } /// /// 참여자 목록 요청 — 현재 참여 중인 룸 기준 /// public async Task RequestParticipants() { if (Context.Items["roomKey"] is not string roomKey || string.IsNullOrEmpty(roomKey)) { await Clients.Caller.ReceiveParticipants([]); return; } var users = await tracker.GetByRoomAsync(roomKey); var memberIDs = users .Where(u => !u.IsGuest && u.MemberID.HasValue) .Select(u => u.MemberID!.Value) .Distinct() .ToList(); if (memberIDs.Count == 0) { await Clients.Caller.ReceiveParticipants([]); return; } // 접속 회원 표시정보 일괄 조회(1쿼리, N+1 회피) — ICON·SID·별명·레벨(등급 Order) using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var participants = await db.Member.AsNoTracking() .Where(m => memberIDs.Contains(m.ID)) .Select(m => new ChatParticipant( m.Name ?? m.SID, false, m.SID, m.Icon, m.MemberGrade != null ? (int)m.MemberGrade.Order : 0)) .OrderBy(p => p.MemberName) .ToListAsync(); await Clients.Caller.ReceiveParticipants(participants); } // 룸 내부 접속자 수 브로드캐스트 private async Task BroadcastParticipantCountAsync(string roomKey) { var count = await tracker.GetCountByRoomAsync(roomKey); await Clients.Group(RoomGroup(roomKey)).ReceiveParticipantCount(count); } private static string RoomGroup(string roomKey) => $"chat:room:{roomKey}"; // 회원 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);