| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404 |
- 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<IChatHubClient>
- {
- private static readonly ConcurrentDictionary<string, DateTime> _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<IAppDbContext>();
- 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);
- }
- /// <summary>
- /// 채널 방 참가 — watch 페이지 진입 시 호출
- /// </summary>
- 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);
- }
- /// <summary>
- /// 채널 방 퇴장 — 페이지 이탈 시 호출
- /// </summary>
- 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;
- }
- /// <summary>
- /// 로그아웃 시 호출 (invoke('Logout'))
- /// </summary>
- public async Task Logout()
- {
- await LeaveChannel();
- await Clients.Caller.Logout("로그아웃 되었습니다.");
- }
- /// <summary>
- /// 채팅 메시지 전송
- /// </summary>
- 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<IAppDbContext>();
- var cache = scope.ServiceProvider.GetRequiredService<ICacheService>();
- 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<MemberDisplayInfo>(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<TitleDisplayInfo>(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);
- }
- /// <summary>
- /// 채팅 기록 요청
- /// </summary>
- 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);
- }
- /// <summary>
- /// 접속자 수 요청 — 현재 참여 중인 채널 기준
- /// </summary>
- 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);
- }
- /// <summary>
- /// 참여자 목록 요청 — 현재 참여 중인 채널 기준
- /// </summary>
- 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);
|