ChatVisitor.cshtml.cs 10 KB

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