ChatHub.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. using Application.Abstractions.Cache;
  2. using Application.Abstractions.Chat;
  3. using Application.Abstractions.Data;
  4. using Application.Helpers;
  5. using SharedKernel.Extensions;
  6. using Microsoft.AspNetCore.SignalR;
  7. using Microsoft.EntityFrameworkCore;
  8. using Microsoft.IdentityModel.JsonWebTokens;
  9. using System.Collections.Concurrent;
  10. namespace Web.Api.Hubs;
  11. /// <summary>
  12. /// 룸 기반 실시간 채팅 허브 (개미투자 D2 M1) — roomKey = main | stock:{code}.
  13. /// 게스트는 수신/히스토리 열람 가능, 전송(SendMessage)은 인증 필수.
  14. /// XP 지급은 IChatRewardHook 으로 위임 (M1 no-op, D3 실구현 — d0 §2.2).
  15. /// </summary>
  16. public sealed class ChatHub(IChatMessageStore messageStore, IChatConnectionTracker tracker, IServiceScopeFactory scopeFactory, IChatRewardHook rewardHook) : Hub<IChatHubClient>
  17. {
  18. private static readonly ConcurrentDictionary<string, DateTime> _lastMessageTime = new();
  19. public override async Task OnConnectedAsync()
  20. {
  21. // 접속 직후에는 어느 룸에 속할지 모름 → 클라이언트가 JoinRoom 명시 호출 대기
  22. // 사용자 메타 정보만 Context.Items 에 준비
  23. var ip = Context.GetHttpContext()?.GetClientIP() ?? "Unknown";
  24. var ua = Context.GetHttpContext()?.GetUserAgent() ?? "Unknown";
  25. ConnectedUser user;
  26. if (Context.User?.Identity?.IsAuthenticated == true)
  27. {
  28. var memberID = GetMemberID();
  29. var memberName = GetMemberName();
  30. string? email = null;
  31. if (memberID.HasValue)
  32. {
  33. using (var scope = scopeFactory.CreateScope())
  34. {
  35. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  36. email = await db.Member.AsNoTracking().Where(x => x.ID == memberID.Value).Select(x => x.Email).FirstOrDefaultAsync();
  37. }
  38. }
  39. user = new ConnectedUser(Context.ConnectionId, memberID, email, memberName, ip, ua, false, DateTime.UtcNow);
  40. }
  41. else
  42. {
  43. user = new ConnectedUser(Context.ConnectionId, null, null, null, ip, ua, true, DateTime.UtcNow);
  44. }
  45. Context.Items["user"] = user;
  46. await base.OnConnectedAsync();
  47. }
  48. public override async Task OnDisconnectedAsync(Exception? exception)
  49. {
  50. _lastMessageTime.TryRemove(Context.ConnectionId, out _);
  51. if (Context.Items["roomKey"] is string roomKey && !string.IsNullOrEmpty(roomKey))
  52. {
  53. await tracker.RemoveAsync(roomKey, Context.ConnectionId);
  54. if (Context.Items["user"] is ConnectedUser user && !user.IsGuest && ChatRoomKeys.IsChannelRoom(roomKey))
  55. {
  56. await Clients.OthersInGroup(RoomGroup(roomKey)).ReceiveSystemMessage($"{user.MemberName}님이 퇴장했습니다.");
  57. }
  58. await BroadcastParticipantCountAsync(roomKey);
  59. }
  60. await base.OnDisconnectedAsync(exception);
  61. }
  62. /// <summary>
  63. /// 룸 참가 — roomKey: main | stock:{code}. 게스트도 참가(읽기) 가능.
  64. /// 종목방은 ChatRoomConfig row 존재 + IsActive 일 때만 오픈 (단계 오픈 — d0 §5-④).
  65. /// </summary>
  66. public async Task JoinRoom(string roomKey)
  67. {
  68. roomKey = roomKey?.Trim() ?? string.Empty;
  69. if (!ChatRoomKeys.IsValid(roomKey))
  70. {
  71. await Clients.Caller.ReceiveSystemMessage("존재하지 않는 채팅방입니다.");
  72. return;
  73. }
  74. using (var scope = scopeFactory.CreateScope())
  75. {
  76. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  77. var cache = scope.ServiceProvider.GetRequiredService<ICacheService>();
  78. var config = await ChatRoomConfigLoader.GetAsync(cache, db, roomKey, default);
  79. if (!ChatRoomConfigLoader.IsRoomOpen(roomKey, config))
  80. {
  81. await Clients.Caller.ReceiveSystemMessage("아직 열리지 않은 채팅방입니다.");
  82. return;
  83. }
  84. }
  85. await JoinRoomInternal(roomKey);
  86. }
  87. /// <summary>
  88. /// 룸 퇴장 — 페이지 이탈 시 호출
  89. /// </summary>
  90. public async Task LeaveRoom()
  91. {
  92. if (Context.Items["roomKey"] is string roomKey && !string.IsNullOrEmpty(roomKey))
  93. {
  94. await LeaveRoomInternal(roomKey);
  95. }
  96. }
  97. /// <summary>
  98. /// [레거시 어댑터] 채널 방 참가 — 기존 watch 페이지 호환용 (Features:Channel 비활성 상태 코드 보존).
  99. /// 내부적으로 channel:{sid} 룸으로 매핑되며 신규 클라이언트는 JoinRoom 을 사용할 것.
  100. /// </summary>
  101. public async Task JoinChannel(string channelSID)
  102. {
  103. if (string.IsNullOrWhiteSpace(channelSID))
  104. {
  105. return;
  106. }
  107. await JoinRoomInternal(ChatRoomKeys.ForChannel(channelSID.Trim()));
  108. }
  109. /// <summary>
  110. /// [레거시 어댑터] 채널 방 퇴장 — LeaveRoom 과 동일
  111. /// </summary>
  112. public async Task LeaveChannel()
  113. {
  114. await LeaveRoom();
  115. }
  116. private async Task JoinRoomInternal(string roomKey)
  117. {
  118. // 이미 다른 룸에 속해있다면 정리
  119. if (Context.Items["roomKey"] is string previous && !string.IsNullOrEmpty(previous) && previous != roomKey)
  120. {
  121. await LeaveRoomInternal(previous);
  122. }
  123. Context.Items["roomKey"] = roomKey;
  124. await Groups.AddToGroupAsync(Context.ConnectionId, RoomGroup(roomKey));
  125. if (Context.Items["user"] is ConnectedUser user)
  126. {
  127. await tracker.AddAsync(roomKey, user);
  128. if (!user.IsGuest && !string.IsNullOrEmpty(user.MemberName))
  129. {
  130. await Clients.Caller.Connected($"{user.MemberName}님, 환영합니다.");
  131. // 입장 브로드캐스트는 소규모 채널 룸만 — main/종목방은 인원 규모상 스팸 방지 목적으로 생략
  132. if (ChatRoomKeys.IsChannelRoom(roomKey))
  133. {
  134. await Clients.OthersInGroup(RoomGroup(roomKey)).ReceiveSystemMessage($"{user.MemberName}님이 입장했습니다.");
  135. }
  136. }
  137. }
  138. // 히스토리 + 참여자 수 초기 전송
  139. var messages = await messageStore.GetRecentMessagesAsync(roomKey, ChatSettings.MaxMessages);
  140. await Clients.Caller.ReceiveHistory(messages);
  141. await BroadcastParticipantCountAsync(roomKey);
  142. }
  143. private async Task LeaveRoomInternal(string roomKey)
  144. {
  145. await Groups.RemoveFromGroupAsync(Context.ConnectionId, RoomGroup(roomKey));
  146. await tracker.RemoveAsync(roomKey, Context.ConnectionId);
  147. if (Context.Items["user"] is ConnectedUser user && !user.IsGuest && ChatRoomKeys.IsChannelRoom(roomKey))
  148. {
  149. await Clients.OthersInGroup(RoomGroup(roomKey)).ReceiveSystemMessage($"{user.MemberName}님이 퇴장했습니다.");
  150. }
  151. await BroadcastParticipantCountAsync(roomKey);
  152. Context.Items["roomKey"] = null;
  153. }
  154. /// <summary>
  155. /// 로그아웃 시 호출 (invoke('Logout'))
  156. /// </summary>
  157. public async Task Logout()
  158. {
  159. await LeaveRoom();
  160. await Clients.Caller.Logout("로그아웃 되었습니다.");
  161. }
  162. /// <summary>
  163. /// 채팅 메시지 전송 — 인증 필수 (게스트는 읽기 전용).
  164. /// 정책: 길이 제한 + rate limit + 룸 SlowMode + ChatBan 검사.
  165. /// </summary>
  166. public async Task SendMessage(string content)
  167. {
  168. if (Context.Items["roomKey"] is not string roomKey || string.IsNullOrEmpty(roomKey))
  169. {
  170. return;
  171. }
  172. if (string.IsNullOrWhiteSpace(content))
  173. {
  174. return;
  175. }
  176. content = content.Trim();
  177. if (content.Length > ChatSettings.MaxContentLength)
  178. {
  179. return;
  180. }
  181. var memberID = GetMemberID();
  182. if (memberID is null)
  183. {
  184. return;
  185. }
  186. var now = DateTime.UtcNow;
  187. ChatMessage message;
  188. using (var scope = scopeFactory.CreateScope())
  189. {
  190. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  191. var cache = scope.ServiceProvider.GetRequiredService<ICacheService>();
  192. // rate limit — 전역 최소 간격 + 룸별 SlowMode 중 큰 값
  193. var config = await ChatRoomConfigLoader.GetAsync(cache, db, roomKey, default);
  194. var minInterval = Math.Max(ChatSettings.RateLimitSeconds, (int)config.SlowModeSeconds);
  195. if (_lastMessageTime.TryGetValue(Context.ConnectionId, out var lastTime))
  196. {
  197. if ((now - lastTime).TotalSeconds < minInterval)
  198. {
  199. return;
  200. }
  201. }
  202. // 채팅 제재 (뮤트/밴) — 룸 지정 또는 전체 제재
  203. if (await ChatBanChecker.IsBannedAsync(db, memberID.Value, roomKey, now, default))
  204. {
  205. await Clients.Caller.ReceiveSystemMessage("채팅이 제한된 상태입니다.");
  206. return;
  207. }
  208. _lastMessageTime[Context.ConnectionId] = now;
  209. // 멤버 표시 정보 — Redis 캐시 5분 TTL. 운영자 변경 후 최대 5분 stale 허용
  210. var memberCacheKey = $"member:{memberID.Value}:displayInfo";
  211. var memberData = await cache.GetAsync<MemberDisplayInfo>(memberCacheKey);
  212. if (memberData is null)
  213. {
  214. memberData = await db.Member.AsNoTracking()
  215. .Where(x => x.ID == memberID.Value)
  216. .Select(x => new MemberDisplayInfo(
  217. x.SID,
  218. x.Name,
  219. x.Icon,
  220. x.MemberGrade != null ? x.MemberGrade.Image : null,
  221. x.MemberGrade != null ? x.MemberGrade.TextColor : null))
  222. .FirstOrDefaultAsync();
  223. if (memberData is null)
  224. {
  225. return;
  226. }
  227. await cache.SetAsync(memberCacheKey, memberData, TimeSpan.FromMinutes(5));
  228. }
  229. var titleData = new TitleDisplayInfo(null, null, null);
  230. message = new ChatMessage(
  231. MemberID: memberID.Value,
  232. MemberSID: memberData.SID,
  233. MemberName: memberData.Name ?? "익명",
  234. Content: content,
  235. SentAt: now,
  236. TitleIconUrl: titleData.IconUrl,
  237. TitleName: titleData.Name,
  238. TitleColor: titleData.Color,
  239. GradeImageUrl: memberData.GradeImage,
  240. GradeTextColor: memberData.GradeTextColor,
  241. MemberIcon: memberData.Icon
  242. );
  243. }
  244. // 룸별 기록 저장
  245. await messageStore.AddMessageAsync(roomKey, message);
  246. // 해당 룸 그룹에만 전송
  247. await Clients.Group(RoomGroup(roomKey)).ReceiveMessage(message);
  248. // XP/포인트 지급 훅 (M1 no-op, D3 실구현) — 실패해도 채팅 흐름에 영향 없어야 함
  249. await rewardHook.OnMessageSentAsync(memberID.Value, roomKey, content);
  250. }
  251. /// <summary>
  252. /// 채팅 기록 요청
  253. /// </summary>
  254. public async Task RequestHistory()
  255. {
  256. if (Context.Items["roomKey"] is not string roomKey || string.IsNullOrEmpty(roomKey))
  257. {
  258. await Clients.Caller.ReceiveHistory([]);
  259. return;
  260. }
  261. var messages = await messageStore.GetRecentMessagesAsync(roomKey, ChatSettings.MaxMessages);
  262. await Clients.Caller.ReceiveHistory(messages);
  263. }
  264. /// <summary>
  265. /// 접속자 수 요청 — 현재 참여 중인 룸 기준
  266. /// </summary>
  267. public async Task RequestParticipantCount()
  268. {
  269. if (Context.Items["roomKey"] is not string roomKey || string.IsNullOrEmpty(roomKey))
  270. {
  271. await Clients.Caller.ReceiveParticipantCount(0);
  272. return;
  273. }
  274. var count = await tracker.GetCountByRoomAsync(roomKey);
  275. await Clients.Caller.ReceiveParticipantCount(count);
  276. }
  277. /// <summary>
  278. /// 참여자 목록 요청 — 현재 참여 중인 룸 기준
  279. /// </summary>
  280. public async Task RequestParticipants()
  281. {
  282. if (Context.Items["roomKey"] is not string roomKey || string.IsNullOrEmpty(roomKey))
  283. {
  284. await Clients.Caller.ReceiveParticipants([]);
  285. return;
  286. }
  287. var users = await tracker.GetByRoomAsync(roomKey);
  288. var participants = users
  289. .Where(u => !u.IsGuest && !string.IsNullOrEmpty(u.MemberName))
  290. .Select(u => new ChatParticipant(u.MemberName!, u.IsGuest))
  291. .DistinctBy(p => p.MemberName)
  292. .OrderBy(p => p.MemberName)
  293. .ToList();
  294. await Clients.Caller.ReceiveParticipants(participants);
  295. }
  296. // 룸 내부 접속자 수 브로드캐스트
  297. private async Task BroadcastParticipantCountAsync(string roomKey)
  298. {
  299. var count = await tracker.GetCountByRoomAsync(roomKey);
  300. await Clients.Group(RoomGroup(roomKey)).ReceiveParticipantCount(count);
  301. }
  302. private static string RoomGroup(string roomKey) => $"chat:room:{roomKey}";
  303. // 회원 ID 조회
  304. private int? GetMemberID()
  305. {
  306. var sub = Context.User?.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
  307. if (int.TryParse(sub, out var memberID))
  308. {
  309. return memberID;
  310. }
  311. return null;
  312. }
  313. // 회원 이름 조회
  314. private string? GetMemberName()
  315. {
  316. return Context.User?.FindFirst(JwtRegisteredClaimNames.Name)?.Value;
  317. }
  318. }
  319. // 채팅 메시지 표시용 캐시 DTO — 파일 한정 (file-scoped)
  320. file sealed record MemberDisplayInfo(string SID, string? Name, string? Icon, string? GradeImage, string? GradeTextColor);
  321. file sealed record TitleDisplayInfo(string? IconUrl, string? Name, string? Color);