using Application.Abstractions.Cache; using Application.Abstractions.Chat; using Application.Abstractions.Data; using Application.Common; using SharedKernel.Helpers; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; using StackExchange.Redis; using System.ComponentModel; using System.ComponentModel.DataAnnotations; namespace Admin.Pages.Member; public class ChatVisitorModel( IChatConnectionTracker tracker, IConnectionMultiplexer redis, IAppDbContext db ) : PageModel { [BindProperty(SupportsGet = true)] public QueryParams Query { get; set; } = new(); public sealed class QueryParams { [Range(1, int.MaxValue)] [DisplayName("페이지 번호")] public int PageNum { get; set; } = 1; [Range(1, 100)] [DisplayName("페이지 목록 수")] public ushort PerPage { get; set; } = 20; [Range(1, 3, ErrorMessage = "{0}이(가) 올바르지 않습니다.")] [DisplayName("검색 조건")] public int? Search { get; set; } [MaxLength(100, ErrorMessage = "{0}은(는) {1}자 이하로 입력하세요.")] [DisplayName("검색어")] public string? Keyword { get; set; } [DisplayName("검색 구분")] public int? Tab { get; set; } } public int TotalRows { get; set; } public int TotalMember { get; set; } public int TotalGuest { get; set; } public int TotalIps { get; set; } public List<( int Num, string ConnectionID, bool IsGuest, string? MemberID, string? Email, string RoomKey, string RoomName, string IpAddress, string Browser, string OS, string Device, string ConnectedAt, string LogoutURL, int Connections )> List { get; set; } = []; public Pagination? Pagination { get; set; } public async Task OnGetAsync(CancellationToken ct) { if (!ModelState.IsValid) { return; } var all = await tracker.GetAllAsync(); TotalRows = all.Count; TotalMember = all.Count(x => !x.User.IsGuest); TotalGuest = all.Count(x => x.User.IsGuest); TotalIps = all.Select(x => x.User.IpAddress).Distinct().Count(); // 룸 키 → 표시명 일괄 조회 (main=메인, stock:{code}=종목명, channel:{sid}=채널명(레거시)) var roomKeys = all.Select(c => c.RoomKey).Distinct().ToArray(); var roomNames = await ResolveRoomNamesAsync(roomKeys, ct); if (Query.Tab == 3) { // IP 목록 탭 var ipGroups = all .GroupBy(x => x.User.IpAddress) .Select(g => new { IpAddress = g.Key, ConnectedAt = g.Min(x => x.User.ConnectedAt), SampleUA = g.First().User.UserAgent, Connections = g.Count() }) .OrderByDescending(x => x.Connections) .ToList(); var skip = (Query.PageNum - 1) * Query.PerPage; var paged = ipGroups.Skip(skip).Take(Query.PerPage).ToList(); int num = skip + 1; List = [..paged.Select(c => ( Num: num++, ConnectionID: "", IsGuest: false, MemberID: (string?)null, Email: (string?)null, RoomKey: "", RoomName: "", c.IpAddress, Browser: ParseUA(c.SampleUA, "browser"), OS: ParseUA(c.SampleUA, "os"), Device: ParseUA(c.SampleUA, "device"), ConnectedAt: c.ConnectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"), LogoutURL: "", c.Connections ))]; Pagination = new Pagination(ipGroups.Count, Query.PageNum, Query.PerPage); } else { // 검색 필터 IEnumerable<( string RoomKey, ConnectedUser User )> filtered = all; if (!string.IsNullOrWhiteSpace(Query.Keyword)) { if (Query.Search == 3) { // SID → MemberID 변환 후 ConnectedUser 매칭 var memberID = await db.Member.AsNoTracking().Where(m => m.SID == Query.Keyword).Select(m => (int?)m.ID).FirstOrDefaultAsync(ct); filtered = memberID.HasValue ? filtered.Where(x => x.User.MemberID.HasValue && x.User.MemberID.Value == memberID.Value) : []; } else if (Query.Search == 1) { filtered = filtered.Where(x => x.User.MemberID.HasValue && x.User.MemberID.Value.ToString() == Query.Keyword); } else { filtered = filtered.Where(x => x.User.Email != null && x.User.Email == Query.Keyword); } } // 탭 필터 filtered = Query.Tab switch { 1 => filtered.Where(x => !x.User.IsGuest), 2 => filtered.Where(x => x.User.IsGuest), _ => filtered }; var sorted = filtered.OrderByDescending(x => x.User.ConnectedAt).ToList(); var skip = (Query.PageNum - 1) * Query.PerPage; var paged = sorted.Skip(skip).Take(Query.PerPage).ToList(); int num = skip + 1; List = [..paged.Select(c => ( Num: num++, ConnectionID: c.User.ConnectionId, c.User.IsGuest, MemberID: c.User.MemberID?.ToString(), c.User.Email, c.RoomKey, RoomName: roomNames.GetValueOrDefault(c.RoomKey, c.RoomKey), c.User.IpAddress, Browser: ParseUA(c.User.UserAgent, "browser"), OS: ParseUA(c.User.UserAgent, "os"), Device: ParseUA(c.User.UserAgent, "device"), ConnectedAt: c.User.ConnectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"), LogoutURL: $"/Member/ChatVisitor?handler=Kick&connectionId={c.User.ConnectionId}", Connections: 0 ))]; Pagination = new Pagination(sorted.Count, Query.PageNum, Query.PerPage); } } public async Task OnGetKickAsync(string connectionId, CancellationToken ct) { try { if (string.IsNullOrEmpty(connectionId)) { throw new Exception("강제 종료할 대상이 없습니다."); } await PublishKick(connectionId); TempData["SuccessMessage"] = "강제 종료되었습니다."; } catch (Exception e) { TempData["ErrorMessages"] = e.Message; } return Redirect($"{Request.Path}{Request.QueryString}"); } public async Task OnPostKickAsync(string[] ids, CancellationToken ct) { try { if (ids.Length == 0) { throw new Exception("강제 종료할 항목을 선택해주세요."); } foreach (var id in ids) { await PublishKick(id); } TempData["SuccessMessage"] = $"{ids.Length}건이 강제 종료되었습니다."; } catch (Exception e) { TempData["ErrorMessages"] = e.Message; } return Redirect($"{Request.Path}{Request.QueryString}"); } // 룸 키 → 표시명 매핑 (main/종목/레거시 채널) private async Task> ResolveRoomNamesAsync(string[] roomKeys, CancellationToken ct) { var names = new Dictionary(); if (roomKeys.Length == 0) { return names; } var stockCodes = roomKeys.Where(ChatRoomKeys.IsStockRoom).Select(c => c[ChatRoomKeys.StockPrefix.Length..]).Distinct().ToArray(); var stockNames = new Dictionary(); if (stockCodes.Length > 0) { stockNames = await db.Stock.AsNoTracking().Where(c => stockCodes.Contains(c.Code)).ToDictionaryAsync(c => c.Code, c => c.Name, ct); } var channelSids = roomKeys.Where(ChatRoomKeys.IsChannelRoom).Select(c => c[ChatRoomKeys.ChannelPrefix.Length..]).Distinct().ToArray(); var channelNames = new Dictionary(); if (channelSids.Length > 0) { channelNames = await db.Channel.AsNoTracking().Where(c => channelSids.Contains(c.SID)).ToDictionaryAsync(c => c.SID, c => c.Name, ct); } foreach (var key in roomKeys) { if (key == ChatRoomKeys.Main) { names[key] = "메인 채팅"; } else if (ChatRoomKeys.IsStockRoom(key)) { var code = key[ChatRoomKeys.StockPrefix.Length..]; names[key] = stockNames.TryGetValue(code, out var stockName) ? $"종목: {stockName}" : key; } else if (ChatRoomKeys.IsChannelRoom(key)) { var sid = key[ChatRoomKeys.ChannelPrefix.Length..]; names[key] = channelNames.TryGetValue(sid, out var channelName) ? $"채널: {channelName}" : key; } else { names[key] = key; } } return names; } // Redis Pub/Sub을 통해 해당 채팅 ConnectionID 클라이언트에게 강제 종료 메시지를 보냄 private async Task PublishKick(string connectionId) { await redis.GetSubscriber().PublishAsync( RedisChannel.Literal(CacheKeys.ChatKickChannel), connectionId ); } // User-Agent 문자열에서 브라우저, OS, 디바이스 정보를 추출하는 헬퍼 메서드 private static string ParseUA(string? ua, string type) { if (string.IsNullOrWhiteSpace(ua)) { return "-"; } return type switch { "browser" => UserAgentParser.ExtractBrowser(ua), "os" => UserAgentParser.ExtractOS(ua), "device" => UserAgentParser.ExtractDevice(ua), _ => "-" }; } }