OnlineUserService.php 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  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. // 每位用户最近一次成功登录
  38. $loginBase = DB::table('user_login');
  39. if ($hasLoginStatus) {
  40. $loginBase->where('status', UserLogin::STATUS_SUCCESS);
  41. }
  42. $latestLogin = $loginBase
  43. ->selectRaw('user_id, MAX(id) as id')
  44. ->groupBy('user_id');
  45. $query = DB::table('users')
  46. ->leftJoin('wallets', 'wallets.member_id', '=', 'users.member_id')
  47. ->leftJoinSub($latestLogin, 'latest_login', function ($join) {
  48. $join->on('latest_login.user_id', '=', 'users.user_id');
  49. })
  50. ->leftJoin('user_login as ul', 'ul.id', '=', 'latest_login.id')
  51. ->where(function ($q) use ($now, $activeSince) {
  52. // 1) 有效 token 会话
  53. $q->whereExists(function ($sub) use ($now) {
  54. $sub->select(DB::raw(1))
  55. ->from('user_session as us')
  56. ->where(function ($w) {
  57. $w->whereColumn('us.user_id', 'users.user_id')
  58. ->orWhereColumn('us.user_id', 'users.member_id');
  59. })
  60. ->where('us.expire_time', '>', $now);
  61. })
  62. // 2) 或最近活跃(unix 秒级时间戳)
  63. ->orWhere(function ($q2) use ($activeSince) {
  64. $q2->where('users.last_active_time', '>', 1000000000)
  65. ->where('users.last_active_time', '>=', $activeSince);
  66. });
  67. })
  68. // 排除虚拟游客
  69. ->where(function ($q) {
  70. $q->whereNull('users.from')
  71. ->orWhere('users.from', '<>', 2);
  72. });
  73. if (!empty($params['member_id'])) {
  74. $mid = $params['member_id'];
  75. $query->where(function ($q) use ($mid) {
  76. $q->where('users.member_id', $mid)->orWhere('users.user_id', $mid);
  77. });
  78. }
  79. if (!empty($params['first_name'])) {
  80. $query->where('users.first_name', 'like', '%' . $params['first_name'] . '%');
  81. }
  82. if (!empty($params['login_ip'])) {
  83. $query->where('ul.login_ip', 'like', '%' . $params['login_ip'] . '%');
  84. }
  85. if (!empty($params['platform']) && $params['platform'] !== '全部' && $hasPlatform) {
  86. $query->where('ul.platform', $params['platform']);
  87. }
  88. $select = [
  89. 'users.id',
  90. 'users.member_id',
  91. 'users.user_id',
  92. 'users.first_name',
  93. 'users.last_active_time',
  94. 'wallets.available_balance',
  95. 'ul.login_ip',
  96. 'ul.created_at as login_time',
  97. ];
  98. if ($hasCountry) {
  99. $select[] = 'ul.country';
  100. }
  101. if ($hasLoginDomain) {
  102. $select[] = 'ul.login_domain';
  103. }
  104. if ($hasPlatform) {
  105. $select[] = 'ul.platform';
  106. }
  107. if ($hasBrowser) {
  108. $select[] = 'ul.browser';
  109. }
  110. if ($hasOs) {
  111. $select[] = 'ul.os';
  112. }
  113. $query->select($select);
  114. $total = (int)(clone $query)
  115. ->reorder()
  116. ->selectRaw('COUNT(DISTINCT users.id) as aggregate')
  117. ->value('aggregate');
  118. $rows = $query
  119. ->orderByDesc('users.last_active_time')
  120. ->offset(($page - 1) * $limit)
  121. ->limit($limit)
  122. ->get();
  123. $data = $rows->map(function ($row) {
  124. return [
  125. 'member_id' => $row->member_id ?: $row->user_id,
  126. 'first_name' => (string)($row->first_name ?? ''),
  127. 'available_balance' => (float)($row->available_balance ?? 0),
  128. 'login_ip' => (string)($row->login_ip ?? ''),
  129. 'location' => (string)($row->country ?? ''),
  130. 'login_domain' => (string)($row->login_domain ?? ''),
  131. 'platform' => (string)(
  132. ($row->platform ?? '') !== ''
  133. ? $row->platform
  134. : self::guessPlatformFromOs((string)($row->os ?? ''))
  135. ),
  136. 'browser' => (string)($row->browser ?? ''),
  137. 'os' => (string)($row->os ?? ''),
  138. 'login_time' => self::formatTime($row->login_time ?? null),
  139. 'last_active_time' => self::formatLastActive($row->last_active_time ?? null),
  140. ];
  141. })->values()->all();
  142. return ['total' => $total, 'data' => $data];
  143. }
  144. private static function formatTime($value): string
  145. {
  146. if ($value === null || $value === '') {
  147. return '';
  148. }
  149. try {
  150. return Carbon::parse($value)->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
  151. } catch (\Throwable $e) {
  152. return (string)$value;
  153. }
  154. }
  155. private static function formatLastActive($value): string
  156. {
  157. if ($value === null || $value === '' || $value === 0 || $value === '0') {
  158. return '';
  159. }
  160. if (is_numeric($value) && (int)$value > 1000000000) {
  161. return Carbon::createFromTimestamp((int)$value, 'Asia/Shanghai')->format('Y-m-d H:i:s');
  162. }
  163. return self::formatTime($value);
  164. }
  165. private static function guessPlatformFromOs(string $os): string
  166. {
  167. $os = strtolower($os);
  168. if (str_contains($os, 'android')) {
  169. return 'Android';
  170. }
  171. if (str_contains($os, 'ios') || str_contains($os, 'iphone') || str_contains($os, 'ipad')) {
  172. return 'iOS';
  173. }
  174. return 'PC';
  175. }
  176. }