ChatHub.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. using Application.Abstractions.Cache;
  2. using Application.Abstractions.Chat;
  3. using Application.Abstractions.Data;
  4. using Application.Helpers;
  5. using Domain.Entities.Members.Logs;
  6. using SharedKernel.Extensions;
  7. using Microsoft.AspNetCore.SignalR;
  8. using Microsoft.EntityFrameworkCore;
  9. using Microsoft.IdentityModel.JsonWebTokens;
  10. using System.Collections.Concurrent;
  11. namespace Web.Api.Hubs;
  12. public sealed class ChatHub(IChatMessageStore messageStore, IChatConnectionTracker tracker, IServiceScopeFactory scopeFactory, IChatLeaderboard leaderboard) : Hub<IChatHubClient>
  13. {
  14. private static readonly ConcurrentDictionary<string, DateTime> _lastMessageTime = new();
  15. public override async Task OnConnectedAsync()
  16. {
  17. // 접속 직후에는 어느 채널에 속할지 모름 → 클라이언트가 JoinChannel 명시 호출 대기
  18. // 사용자 메타 정보만 Context.Items 에 준비
  19. var ip = Context.GetHttpContext()?.GetClientIP() ?? "Unknown";
  20. var ua = Context.GetHttpContext()?.GetUserAgent() ?? "Unknown";
  21. ConnectedUser user;
  22. if (Context.User?.Identity?.IsAuthenticated == true)
  23. {
  24. var memberID = GetMemberID();
  25. var memberName = GetMemberName();
  26. string? email = null;
  27. if (memberID.HasValue)
  28. {
  29. using (var scope = scopeFactory.CreateScope())
  30. {
  31. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  32. email = await db.Member.AsNoTracking().Where(x => x.ID == memberID.Value).Select(x => x.Email).FirstOrDefaultAsync();
  33. }
  34. }
  35. user = new ConnectedUser(Context.ConnectionId, memberID, email, memberName, ip, ua, false, DateTime.UtcNow);
  36. }
  37. else
  38. {
  39. user = new ConnectedUser(Context.ConnectionId, null, null, null, ip, ua, true, DateTime.UtcNow);
  40. }
  41. Context.Items["user"] = user;
  42. await base.OnConnectedAsync();
  43. }
  44. public override async Task OnDisconnectedAsync(Exception? exception)
  45. {
  46. _lastMessageTime.TryRemove(Context.ConnectionId, out _);
  47. if (Context.Items["channelSID"] is string channelSID && !string.IsNullOrEmpty(channelSID))
  48. {
  49. await tracker.RemoveAsync(channelSID, Context.ConnectionId);
  50. if (Context.Items["user"] is ConnectedUser user && !user.IsGuest)
  51. {
  52. await Clients.OthersInGroup(ChannelGroup(channelSID)).ReceiveSystemMessage($"{user.MemberName}님이 퇴장했습니다.");
  53. }
  54. await BroadcastParticipantCountAsync(channelSID);
  55. }
  56. await base.OnDisconnectedAsync(exception);
  57. }
  58. /// <summary>
  59. /// 채널 방 참가 — watch 페이지 진입 시 호출
  60. /// </summary>
  61. public async Task JoinChannel(string channelSID)
  62. {
  63. if (string.IsNullOrWhiteSpace(channelSID))
  64. {
  65. return;
  66. }
  67. // 이미 다른 채널에 속해있다면 정리
  68. if (Context.Items["channelSID"] is string previous && !string.IsNullOrEmpty(previous) && previous != channelSID)
  69. {
  70. await LeaveChannelInternal(previous);
  71. }
  72. Context.Items["channelSID"] = channelSID;
  73. await Groups.AddToGroupAsync(Context.ConnectionId, ChannelGroup(channelSID));
  74. if (Context.Items["user"] is ConnectedUser user)
  75. {
  76. await tracker.AddAsync(channelSID, user);
  77. if (!user.IsGuest && !string.IsNullOrEmpty(user.MemberName))
  78. {
  79. await Clients.Caller.Connected($"{user.MemberName}님, 환영합니다.");
  80. await Clients.OthersInGroup(ChannelGroup(channelSID)).ReceiveSystemMessage($"{user.MemberName}님이 입장했습니다.");
  81. }
  82. }
  83. // 히스토리 + 참여자 수 초기 전송
  84. var messages = await messageStore.GetRecentMessagesAsync(channelSID, ChatSettings.MaxMessages);
  85. await Clients.Caller.ReceiveHistory(messages);
  86. await BroadcastParticipantCountAsync(channelSID);
  87. }
  88. /// <summary>
  89. /// 채널 방 퇴장 — 페이지 이탈 시 호출
  90. /// </summary>
  91. public async Task LeaveChannel()
  92. {
  93. if (Context.Items["channelSID"] is string channelSID && !string.IsNullOrEmpty(channelSID))
  94. {
  95. await LeaveChannelInternal(channelSID);
  96. }
  97. }
  98. private async Task LeaveChannelInternal(string channelSID)
  99. {
  100. await Groups.RemoveFromGroupAsync(Context.ConnectionId, ChannelGroup(channelSID));
  101. await tracker.RemoveAsync(channelSID, Context.ConnectionId);
  102. if (Context.Items["user"] is ConnectedUser user && !user.IsGuest)
  103. {
  104. await Clients.OthersInGroup(ChannelGroup(channelSID)).ReceiveSystemMessage($"{user.MemberName}님이 퇴장했습니다.");
  105. }
  106. await BroadcastParticipantCountAsync(channelSID);
  107. Context.Items["channelSID"] = null;
  108. }
  109. /// <summary>
  110. /// 로그아웃 시 호출 (invoke('Logout'))
  111. /// </summary>
  112. public async Task Logout()
  113. {
  114. await LeaveChannel();
  115. await Clients.Caller.Logout("로그아웃 되었습니다.");
  116. }
  117. /// <summary>
  118. /// 채팅 메시지 전송
  119. /// </summary>
  120. public async Task SendMessage(string content)
  121. {
  122. if (Context.Items["channelSID"] is not string channelSID || string.IsNullOrEmpty(channelSID))
  123. {
  124. return;
  125. }
  126. if (string.IsNullOrWhiteSpace(content))
  127. {
  128. return;
  129. }
  130. content = content.Trim();
  131. if (content.Length > ChatSettings.MaxContentLength)
  132. {
  133. return;
  134. }
  135. var now = DateTime.UtcNow;
  136. if (_lastMessageTime.TryGetValue(Context.ConnectionId, out var lastTime))
  137. {
  138. if ((now - lastTime).TotalSeconds < ChatSettings.RateLimitSeconds)
  139. {
  140. return;
  141. }
  142. }
  143. _lastMessageTime[Context.ConnectionId] = now;
  144. var memberID = GetMemberID();
  145. if (memberID is null)
  146. {
  147. return;
  148. }
  149. ChatMessage message;
  150. using (var scope = scopeFactory.CreateScope())
  151. {
  152. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  153. var cache = scope.ServiceProvider.GetRequiredService<ICacheService>();
  154. var cfg = await ChatExpConfigLoader.GetAsync(cache, db, default);
  155. var channelID = await db.Channel.AsNoTracking().Where(c => c.SID == channelSID).Select(c => (int?)c.ID).FirstOrDefaultAsync();
  156. if (channelID is null)
  157. {
  158. return;
  159. }
  160. // 멤버 표시 정보 — Redis 캐시 5분 TTL. 운영자 변경 후 최대 5분 stale 허용
  161. var memberCacheKey = $"member:{memberID.Value}:displayInfo";
  162. var memberData = await cache.GetAsync<MemberDisplayInfo>(memberCacheKey);
  163. if (memberData is null)
  164. {
  165. memberData = await db.Member.AsNoTracking()
  166. .Where(x => x.ID == memberID.Value)
  167. .Select(x => new MemberDisplayInfo(
  168. x.SID,
  169. x.Name,
  170. x.Icon,
  171. x.MemberGrade != null ? x.MemberGrade.Image : null,
  172. x.MemberGrade != null ? x.MemberGrade.TextColor : null))
  173. .FirstOrDefaultAsync();
  174. if (memberData is null)
  175. {
  176. return;
  177. }
  178. await cache.SetAsync(memberCacheKey, memberData, TimeSpan.FromMinutes(5));
  179. }
  180. var titleData = new TitleDisplayInfo(null, null, null);
  181. // ── XP 적립 (방송 세션 활성 + 최소 글자수 + 세션 상한 미초과) ──
  182. int? leaderboardRank = null;
  183. int? leaderboardXp = null;
  184. if (cfg.ChatXpPerMessage > 0 && content.Length >= cfg.MinContentLength)
  185. {
  186. var session = await db.BroadcastSession.Where(s => s.ChannelID == channelID.Value && !s.IsFinalized).OrderByDescending(s => s.CreatedAt).Select(s => new { s.ID }).FirstOrDefaultAsync();
  187. if (session is not null)
  188. {
  189. // 본인 세션 누적 XP — 채팅 reason 합 + 전체 합을 한 쿼리로 동시 계산 (이전 두 SUM → 한 GROUP BY)
  190. var sums = await db.MemberExpLog.AsNoTracking()
  191. .Where(e => e.MemberID == memberID.Value && e.BroadcastSessionID == session.ID)
  192. .GroupBy(e => e.Reason == "채팅(라이브)")
  193. .Select(g => new { IsChat = g.Key, Sum = g.Sum(x => x.Amount) })
  194. .ToListAsync();
  195. var sessionChatXp = sums.Where(x => x.IsChat).Sum(x => x.Sum);
  196. var myXp = sums.Sum(x => x.Sum);
  197. if (sessionChatXp < cfg.ChatXpSessionLimit)
  198. {
  199. var grant = Math.Min(cfg.ChatXpPerMessage, cfg.ChatXpSessionLimit - sessionChatXp);
  200. var stats = await db.MemberStats.FirstOrDefaultAsync(s => s.MemberID == memberID.Value);
  201. if (stats is not null)
  202. {
  203. stats.Exp += grant;
  204. var logEntry = MemberExpLog.CreateForBroadcast(
  205. memberID.Value,
  206. "채팅(라이브)",
  207. grant,
  208. stats.Exp,
  209. session.ID,
  210. channelID.Value
  211. );
  212. db.MemberExpLog.Add(logEntry);
  213. await db.SaveChangesAsync();
  214. // grant 반영 후 메모리상 합 갱신 (DB 재조회 회피)
  215. myXp += grant;
  216. }
  217. }
  218. // 본인 랭크 계산 (Top N 크라운 여부) — Redis sorted set 캐시 기반 (TTL 5초)
  219. // myXp 는 grant 반영된 메모리 값, higherCount 는 캐시 기준 (최대 5초 stale 가능)
  220. if (myXp > 0)
  221. {
  222. var rankResult = await leaderboard.GetRankAsync(session.ID, memberID.Value, db);
  223. var higherCount = rankResult?.HigherCount ?? 0;
  224. var rank = higherCount + 1;
  225. leaderboardXp = myXp;
  226. if (rank <= cfg.CrownTopN)
  227. {
  228. leaderboardRank = rank;
  229. }
  230. }
  231. }
  232. }
  233. message = new ChatMessage(
  234. MemberID: memberID.Value,
  235. MemberSID: memberData.SID,
  236. MemberName: memberData.Name ?? "익명",
  237. Content: content,
  238. SentAt: now,
  239. TitleIconUrl: titleData.IconUrl,
  240. TitleName: titleData.Name,
  241. TitleColor: titleData.Color,
  242. GradeImageUrl: memberData.GradeImage,
  243. GradeTextColor: memberData.GradeTextColor,
  244. MemberIcon: memberData.Icon,
  245. LeaderboardRank: leaderboardRank,
  246. LeaderboardXp: leaderboardXp
  247. );
  248. }
  249. // 채널별 기록 저장
  250. await messageStore.AddMessageAsync(channelSID, message);
  251. // 해당 채널 그룹에만 전송
  252. await Clients.Group(ChannelGroup(channelSID)).ReceiveMessage(message);
  253. }
  254. /// <summary>
  255. /// 채팅 기록 요청
  256. /// </summary>
  257. public async Task RequestHistory()
  258. {
  259. if (Context.Items["channelSID"] is not string channelSID || string.IsNullOrEmpty(channelSID))
  260. {
  261. await Clients.Caller.ReceiveHistory([]);
  262. return;
  263. }
  264. var messages = await messageStore.GetRecentMessagesAsync(channelSID, ChatSettings.MaxMessages);
  265. await Clients.Caller.ReceiveHistory(messages);
  266. }
  267. /// <summary>
  268. /// 접속자 수 요청 — 현재 참여 중인 채널 기준
  269. /// </summary>
  270. public async Task RequestParticipantCount()
  271. {
  272. if (Context.Items["channelSID"] is not string channelSID || string.IsNullOrEmpty(channelSID))
  273. {
  274. await Clients.Caller.ReceiveParticipantCount(0);
  275. return;
  276. }
  277. var count = await tracker.GetCountByChannelAsync(channelSID);
  278. await Clients.Caller.ReceiveParticipantCount(count);
  279. }
  280. /// <summary>
  281. /// 참여자 목록 요청 — 현재 참여 중인 채널 기준
  282. /// </summary>
  283. public async Task RequestParticipants()
  284. {
  285. if (Context.Items["channelSID"] is not string channelSID || string.IsNullOrEmpty(channelSID))
  286. {
  287. await Clients.Caller.ReceiveParticipants([]);
  288. return;
  289. }
  290. var users = await tracker.GetByChannelAsync(channelSID);
  291. var participants = users
  292. .Where(u => !u.IsGuest && !string.IsNullOrEmpty(u.MemberName))
  293. .Select(u => new ChatParticipant(u.MemberName!, u.IsGuest))
  294. .DistinctBy(p => p.MemberName)
  295. .OrderBy(p => p.MemberName)
  296. .ToList();
  297. await Clients.Caller.ReceiveParticipants(participants);
  298. }
  299. // 채널 내부 접속자 수 브로드캐스트
  300. private async Task BroadcastParticipantCountAsync(string channelSID)
  301. {
  302. var count = await tracker.GetCountByChannelAsync(channelSID);
  303. await Clients.Group(ChannelGroup(channelSID)).ReceiveParticipantCount(count);
  304. }
  305. private static string ChannelGroup(string channelSID) => $"chat:channel:{channelSID}";
  306. // 회원 ID 조회
  307. private int? GetMemberID()
  308. {
  309. var sub = Context.User?.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
  310. if (int.TryParse(sub, out var memberID))
  311. {
  312. return memberID;
  313. }
  314. return null;
  315. }
  316. // 회원 이름 조회
  317. private string? GetMemberName()
  318. {
  319. return Context.User?.FindFirst(JwtRegisteredClaimNames.Name)?.Value;
  320. }
  321. }
  322. // 채팅 메시지 표시용 캐시 DTO — 파일 한정 (file-scoped)
  323. file sealed record MemberDisplayInfo(string SID, string? Name, string? Icon, string? GradeImage, string? GradeTextColor);
  324. file sealed record TitleDisplayInfo(string? IconUrl, string? Name, string? Color);