Visitor.cshtml.cs 7.8 KB

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