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 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 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);