Visitor.cshtml.cs 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. using Application.Abstractions.Cache;
  2. using Application.Abstractions.Chat;
  3. using Application.Common;
  4. using SharedKernel.Helpers;
  5. using Microsoft.AspNetCore.Mvc;
  6. using Microsoft.AspNetCore.Mvc.RazorPages;
  7. using StackExchange.Redis;
  8. using System.ComponentModel;
  9. using System.ComponentModel.DataAnnotations;
  10. namespace Admin.Pages.Member;
  11. public class VisitorModel(
  12. IChatConnectionTracker tracker,
  13. IConnectionMultiplexer redis
  14. ) : PageModel {
  15. [BindProperty(SupportsGet = true)]
  16. public QueryParams Query { get; set; } = new();
  17. public sealed class QueryParams
  18. {
  19. [Range(1, int.MaxValue)]
  20. [DisplayName("페이지 번호")]
  21. public int PageNum { get; set; } = 1;
  22. [Range(1, 100)]
  23. [DisplayName("페이지 목록 수")]
  24. public ushort PerPage { get; set; } = 20;
  25. [Range(1, 2, ErrorMessage = "{0}이(가) 올바르지 않습니다.")]
  26. [DisplayName("검색 조건")]
  27. public int? Search { get; set; }
  28. [MaxLength(100, ErrorMessage = "{0}은(는) {1}자 이하로 입력하세요.")]
  29. [DisplayName("검색어")]
  30. public string? Keyword { get; set; }
  31. [DisplayName("검색 구분")]
  32. public int? Tab { get; set; }
  33. }
  34. public int TotalRows { get; set; }
  35. public int TotalMember { get; set; }
  36. public int TotalGuest { get; set; }
  37. public int TotalIps { get; set; }
  38. public List<(
  39. int Num,
  40. string ConnectionID,
  41. bool IsGuest,
  42. string? MemberID,
  43. string? Email,
  44. string IpAddress,
  45. string Browser,
  46. string OS,
  47. string Device,
  48. string ConnectedAt,
  49. string LogoutURL,
  50. int Connections
  51. )> List { get; set; } = [];
  52. public Pagination? Pagination { get; set; }
  53. public async Task OnGetAsync(CancellationToken _)
  54. {
  55. if (!ModelState.IsValid)
  56. {
  57. return;
  58. }
  59. var all = await tracker.GetAllAsync();
  60. TotalRows = all.Count;
  61. TotalMember = all.Count(x => !x.IsGuest);
  62. TotalGuest = all.Count(x => x.IsGuest);
  63. TotalIps = all.Select(x => x.IpAddress).Distinct().Count();
  64. if (Query.Tab == 3)
  65. {
  66. // IP 목록 탭
  67. var ipGroups = all
  68. .GroupBy(x => x.IpAddress)
  69. .Select(g => new
  70. {
  71. IpAddress = g.Key,
  72. ConnectedAt = g.Min(x => x.ConnectedAt),
  73. SampleUA = g.First().UserAgent,
  74. Connections = g.Count()
  75. })
  76. .OrderByDescending(x => x.Connections)
  77. .ToList();
  78. var skip = (Query.PageNum - 1) * Query.PerPage;
  79. var paged = ipGroups.Skip(skip).Take(Query.PerPage).ToList();
  80. int num = skip + 1;
  81. List = [..paged.Select(c => (
  82. Num: num++,
  83. ConnectionID: "",
  84. IsGuest: false,
  85. MemberID: (string?)null,
  86. Email: (string?)null,
  87. c.IpAddress,
  88. Browser: ParseUA(c.SampleUA, "browser"),
  89. OS: ParseUA(c.SampleUA, "os"),
  90. Device: ParseUA(c.SampleUA, "device"),
  91. ConnectedAt: c.ConnectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"),
  92. LogoutURL: "",
  93. c.Connections
  94. ))];
  95. Pagination = new Pagination(ipGroups.Count, Query.PageNum, Query.PerPage);
  96. }
  97. else
  98. {
  99. // 검색 필터
  100. IEnumerable<ConnectedUser> filtered = all;
  101. if (!string.IsNullOrWhiteSpace(Query.Keyword))
  102. {
  103. filtered = Query.Search == 1
  104. ? filtered.Where(x => x.MemberID.HasValue && x.MemberID.Value.ToString() == Query.Keyword)
  105. : filtered.Where(x => x.Email != null && x.Email == Query.Keyword);
  106. }
  107. // 탭 필터
  108. filtered = Query.Tab switch
  109. {
  110. 1 => filtered.Where(x => !x.IsGuest),
  111. 2 => filtered.Where(x => x.IsGuest),
  112. _ => filtered
  113. };
  114. var sorted = filtered.OrderByDescending(x => x.ConnectedAt).ToList();
  115. var skip = (Query.PageNum - 1) * Query.PerPage;
  116. var paged = sorted.Skip(skip).Take(Query.PerPage).ToList();
  117. int num = skip + 1;
  118. var qs = Request.QueryString.ToString();
  119. List = [..paged.Select(c => (
  120. Num: num++,
  121. ConnectionID: c.ConnectionId,
  122. c.IsGuest,
  123. MemberID: c.MemberID?.ToString(),
  124. c.Email,
  125. c.IpAddress,
  126. Browser: ParseUA(c.UserAgent, "browser"),
  127. OS: ParseUA(c.UserAgent, "os"),
  128. Device: ParseUA(c.UserAgent, "device"),
  129. ConnectedAt: c.ConnectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"),
  130. LogoutURL: $"/Member/Visitor?handler=Kick&connectionId={c.ConnectionId}",
  131. Connections: 0
  132. ))];
  133. Pagination = new Pagination(sorted.Count, Query.PageNum, Query.PerPage);
  134. }
  135. }
  136. public async Task<IActionResult> OnGetKickAsync(string connectionId, CancellationToken ct)
  137. {
  138. try
  139. {
  140. if (string.IsNullOrEmpty(connectionId))
  141. {
  142. throw new Exception("강제 종료할 대상이 없습니다.");
  143. }
  144. await PublishKick(connectionId);
  145. TempData["SuccessMessage"] = "강제 종료되었습니다.";
  146. }
  147. catch (Exception e)
  148. {
  149. TempData["ErrorMessages"] = e.Message;
  150. }
  151. return RedirectToPage(Query);
  152. }
  153. public async Task<IActionResult> OnPostKickAsync(string[] ids, CancellationToken ct)
  154. {
  155. try
  156. {
  157. if (ids.Length == 0)
  158. {
  159. throw new Exception("강제 종료할 항목을 선택해주세요.");
  160. }
  161. foreach (var id in ids)
  162. {
  163. await PublishKick(id);
  164. }
  165. TempData["SuccessMessage"] = $"{ids.Length}건이 강제 종료되었습니다.";
  166. }
  167. catch (Exception e)
  168. {
  169. TempData["ErrorMessages"] = e.Message;
  170. }
  171. return RedirectToPage(Query);
  172. }
  173. // Redis Pub/Sub을 통해 해당 ConnectionID를 구독 중인 클라이언트에게 강제 종료 메시지를 보냄
  174. private async Task PublishKick(string connectionId)
  175. {
  176. await redis.GetSubscriber().PublishAsync(
  177. RedisChannel.Literal(CacheKeys.ChatKickChannel), connectionId
  178. );
  179. }
  180. // User-Agent 문자열에서 브라우저, OS, 디바이스 정보를 추출하는 헬퍼 메서드
  181. private static string ParseUA(string? ua, string type)
  182. {
  183. if (string.IsNullOrWhiteSpace(ua))
  184. {
  185. return "-";
  186. }
  187. return type switch
  188. {
  189. "browser" => UserAgentParser.ExtractBrowser(ua),
  190. "os" => UserAgentParser.ExtractOS(ua),
  191. "device" => UserAgentParser.ExtractDevice(ua),
  192. _ => "-"
  193. };
  194. }
  195. }