using Application.Abstractions.Cache; using Application.Abstractions.Chat; using Application.Common; using SharedKernel.Helpers; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using StackExchange.Redis; using System.ComponentModel; using System.ComponentModel.DataAnnotations; namespace Admin.Pages.Member; public class VisitorModel( IChatConnectionTracker tracker, IConnectionMultiplexer redis ) : 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, 2, 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 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 _) { if (!ModelState.IsValid) { return; } var all = await tracker.GetAllAsync(); TotalRows = all.Count; TotalMember = all.Count(x => !x.IsGuest); TotalGuest = all.Count(x => x.IsGuest); TotalIps = all.Select(x => x.IpAddress).Distinct().Count(); if (Query.Tab == 3) { // IP 목록 탭 var ipGroups = all .GroupBy(x => x.IpAddress) .Select(g => new { IpAddress = g.Key, ConnectedAt = g.Min(x => x.ConnectedAt), SampleUA = g.First().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, 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 filtered = all; if (!string.IsNullOrWhiteSpace(Query.Keyword)) { filtered = Query.Search == 1 ? filtered.Where(x => x.MemberID.HasValue && x.MemberID.Value.ToString() == Query.Keyword) : filtered.Where(x => x.Email != null && x.Email == Query.Keyword); } // 탭 필터 filtered = Query.Tab switch { 1 => filtered.Where(x => !x.IsGuest), 2 => filtered.Where(x => x.IsGuest), _ => filtered }; var sorted = filtered.OrderByDescending(x => x.ConnectedAt).ToList(); var skip = (Query.PageNum - 1) * Query.PerPage; var paged = sorted.Skip(skip).Take(Query.PerPage).ToList(); int num = skip + 1; var qs = Request.QueryString.ToString(); List = [..paged.Select(c => ( Num: num++, ConnectionID: c.ConnectionId, c.IsGuest, MemberID: c.MemberID?.ToString(), c.Email, c.IpAddress, Browser: ParseUA(c.UserAgent, "browser"), OS: ParseUA(c.UserAgent, "os"), Device: ParseUA(c.UserAgent, "device"), ConnectedAt: c.ConnectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"), LogoutURL: $"/Member/Visitor?handler=Kick&connectionId={c.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 RedirectToPage(Query); } 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 RedirectToPage(Query); } // 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), _ => "-" }; } }