OnlineUserService.php 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. <?php
  2. namespace App\Services;
  3. use App\Models\User;
  4. use App\Models\UserLogin;
  5. use Carbon\Carbon;
  6. use Illuminate\Support\Facades\DB;
  7. use Illuminate\Support\Facades\Schema;
  8. /**
  9. * 在线用户列表(只读)
  10. *
  11. * 在线判定(满足其一即可):
  12. * 1. 存在未过期 user_session(expire_time 为 unix 时间戳)
  13. * 2. last_active_time 在最近 ONLINE_WINDOW_SECONDS 内(unix 时间戳)
  14. *
  15. * 说明:last_active_time 多半只在登录时更新,仅靠它会导致「有会话却查不到」。
  16. */
  17. class OnlineUserService
  18. {
  19. /** 最近活跃兜底窗口(秒) */
  20. public const ONLINE_WINDOW_SECONDS = 1800;
  21. /**
  22. * @param array{page?:int,limit?:int,member_id?:string,first_name?:string,login_ip?:string,platform?:string} $params
  23. * @return array{total:int,data:list<array>}
  24. */
  25. public static function list(array $params): array
  26. {
  27. $page = max(1, (int)($params['page'] ?? 1));
  28. $limit = min(200, max(1, (int)($params['limit'] ?? 15)));
  29. $now = time();
  30. $activeSince = $now - self::ONLINE_WINDOW_SECONDS;
  31. $hasLoginStatus = Schema::hasColumn('user_login', 'status');
  32. $hasLoginDomain = Schema::hasColumn('user_login', 'login_domain');
  33. $hasPlatform = Schema::hasColumn('user_login', 'platform');
  34. $hasBrowser = Schema::hasColumn('user_login', 'browser');
  35. $hasOs = Schema::hasColumn('user_login', 'os');
  36. $hasCountry = Schema::hasColumn('user_login', 'country');
  37. // 先筛在线用户 ID(避免 join 干扰 count / 前缀别名问题)
  38. // user_session.user_id 与 users.user_id 排序规则可能不一致(unicode_ci vs general_ci),比较时统一 COLLATE
  39. $onlineIdsQuery = DB::table('users')
  40. ->where(function ($q) use ($now, $activeSince) {
  41. $q->whereExists(function ($sub) use ($now) {
  42. $sub->select(DB::raw(1))
  43. ->from('user_session')
  44. ->whereRaw(
  45. '(user_session.user_id COLLATE utf8mb4_general_ci = users.user_id COLLATE utf8mb4_general_ci'
  46. . ' OR user_session.user_id COLLATE utf8mb4_general_ci = users.member_id COLLATE utf8mb4_general_ci)'
  47. )
  48. ->where('user_session.expire_time', '>', $now);
  49. })->orWhere(function ($q2) use ($activeSince) {
  50. $q2->where('users.last_active_time', '>', 1000000000)
  51. ->where('users.last_active_time', '>=', $activeSince);
  52. });
  53. })
  54. ->where(function ($q) {
  55. $q->whereNull('users.from')->orWhere('users.from', '<>', 2);
  56. });
  57. if (!empty($params['member_id'])) {
  58. $mid = $params['member_id'];
  59. $onlineIdsQuery->where(function ($q) use ($mid) {
  60. $q->where('users.member_id', $mid)->orWhere('users.user_id', $mid);
  61. });
  62. }
  63. if (!empty($params['first_name'])) {
  64. $onlineIdsQuery->where('users.first_name', 'like', '%' . $params['first_name'] . '%');
  65. }
  66. // 需要按登录 IP / 平台筛选时,再关联最近登录
  67. $needLoginFilter = !empty($params['login_ip'])
  68. || (!empty($params['platform']) && $params['platform'] !== '全部' && $hasPlatform);
  69. if ($needLoginFilter) {
  70. $loginFilter = DB::table('user_login as ul')
  71. ->select('ul.user_id')
  72. ->whereIn('ul.id', function ($sub) use ($hasLoginStatus) {
  73. $sub->from('user_login')
  74. ->selectRaw('MAX(id)')
  75. ->when($hasLoginStatus, fn ($q) => $q->where('status', UserLogin::STATUS_SUCCESS))
  76. ->groupBy('user_id');
  77. });
  78. if (!empty($params['login_ip'])) {
  79. $loginFilter->where('ul.login_ip', 'like', '%' . $params['login_ip'] . '%');
  80. }
  81. if (!empty($params['platform']) && $params['platform'] !== '全部' && $hasPlatform) {
  82. $loginFilter->where('ul.platform', $params['platform']);
  83. }
  84. $filteredUserIds = $loginFilter->pluck('ul.user_id')->all();
  85. if ($filteredUserIds === []) {
  86. return ['total' => 0, 'data' => []];
  87. }
  88. $onlineIdsQuery->where(function ($q) use ($filteredUserIds) {
  89. $q->whereIn('users.user_id', $filteredUserIds)
  90. ->orWhereIn('users.member_id', $filteredUserIds);
  91. });
  92. }
  93. $total = (int)(clone $onlineIdsQuery)->count('users.id');
  94. $pageIds = (clone $onlineIdsQuery)
  95. ->orderByDesc('users.last_active_time')
  96. ->offset(($page - 1) * $limit)
  97. ->limit($limit)
  98. ->pluck('users.id')
  99. ->all();
  100. if ($pageIds === []) {
  101. return ['total' => $total, 'data' => []];
  102. }
  103. // 最近登录子查询
  104. $latestLoginSql = DB::table('user_login')
  105. ->selectRaw('user_id, MAX(id) as max_id')
  106. ->when($hasLoginStatus, fn ($q) => $q->where('status', UserLogin::STATUS_SUCCESS))
  107. ->groupBy('user_id');
  108. $detailQuery = DB::table('users')
  109. ->leftJoin('wallets', function ($join) {
  110. $join->whereRaw('wallets.member_id COLLATE utf8mb4_general_ci = users.member_id COLLATE utf8mb4_general_ci');
  111. })
  112. ->leftJoinSub($latestLoginSql, 'll', function ($join) {
  113. $join->whereRaw('ll.user_id COLLATE utf8mb4_general_ci = users.user_id COLLATE utf8mb4_general_ci');
  114. })
  115. ->leftJoin('user_login as ul', 'ul.id', '=', 'll.max_id')
  116. ->whereIn('users.id', $pageIds);
  117. $select = [
  118. 'users.id',
  119. 'users.member_id',
  120. 'users.user_id',
  121. 'users.first_name',
  122. 'users.last_active_time',
  123. 'wallets.available_balance',
  124. 'ul.login_ip',
  125. 'ul.created_at as login_time',
  126. ];
  127. if ($hasCountry) {
  128. $select[] = 'ul.country';
  129. }
  130. if ($hasLoginDomain) {
  131. $select[] = 'ul.login_domain';
  132. }
  133. if ($hasPlatform) {
  134. $select[] = 'ul.platform';
  135. }
  136. if ($hasBrowser) {
  137. $select[] = 'ul.browser';
  138. }
  139. if ($hasOs) {
  140. $select[] = 'ul.os';
  141. }
  142. $rows = $detailQuery
  143. ->select($select)
  144. ->orderByDesc('users.last_active_time')
  145. ->get();
  146. $data = $rows->map(function ($row) {
  147. return [
  148. 'member_id' => $row->member_id ?: $row->user_id,
  149. 'first_name' => (string)($row->first_name ?? ''),
  150. 'available_balance' => (float)($row->available_balance ?? 0),
  151. 'login_ip' => (string)($row->login_ip ?? ''),
  152. 'location' => (string)($row->country ?? ''),
  153. 'login_domain' => (string)($row->login_domain ?? ''),
  154. 'platform' => (string)(
  155. ($row->platform ?? '') !== ''
  156. ? $row->platform
  157. : self::guessPlatformFromOs((string)($row->os ?? ''))
  158. ),
  159. 'browser' => (string)($row->browser ?? ''),
  160. 'os' => (string)($row->os ?? ''),
  161. 'login_time' => self::formatTime($row->login_time ?? null),
  162. 'last_active_time' => self::formatLastActive($row->last_active_time ?? null),
  163. ];
  164. })->values()->all();
  165. return ['total' => $total, 'data' => $data];
  166. }
  167. private static function formatTime($value): string
  168. {
  169. if ($value === null || $value === '') {
  170. return '';
  171. }
  172. try {
  173. return Carbon::parse($value)->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
  174. } catch (\Throwable $e) {
  175. return (string)$value;
  176. }
  177. }
  178. private static function formatLastActive($value): string
  179. {
  180. if ($value === null || $value === '' || $value === 0 || $value === '0') {
  181. return '';
  182. }
  183. if (is_numeric($value) && (int)$value > 1000000000) {
  184. return Carbon::createFromTimestamp((int)$value, 'Asia/Shanghai')->format('Y-m-d H:i:s');
  185. }
  186. return self::formatTime($value);
  187. }
  188. private static function guessPlatformFromOs(string $os): string
  189. {
  190. $os = strtolower($os);
  191. if (str_contains($os, 'android')) {
  192. return 'Android';
  193. }
  194. if (str_contains($os, 'ios') || str_contains($os, 'iphone') || str_contains($os, 'ipad')) {
  195. return 'iOS';
  196. }
  197. return 'PC';
  198. }
  199. }