doge пре 2 часа
родитељ
комит
dcac098755

+ 0 - 152
app/Helpers/LoginClientInfo.php

@@ -1,152 +0,0 @@
-<?php
-
-namespace App\Helpers;
-
-/**
- * 登录客户端信息:国家/地区、浏览器、操作系统
- */
-class LoginClientInfo
-{
-    /**
-     * 解析请求中的客户端信息
-     */
-    public static function fromRequest(?string $ip = null, ?string $userAgent = null): array
-    {
-        $ip = $ip ?: (string)request()->ip();
-        $userAgent = $userAgent ?? (string)request()->userAgent();
-
-        return [
-            'login_ip' => $ip,
-            'country' => self::resolveCountry($ip),
-            'browser' => self::parseBrowser($userAgent),
-            'os' => self::parseOs($userAgent),
-            'user_agent' => mb_substr($userAgent, 0, 500),
-        ];
-    }
-
-    /**
-     * 国家/地区:优先 Cloudflare 头,其次 ip-api
-     */
-    public static function resolveCountry(string $ip): string
-    {
-        if ($ip === '' || $ip === '127.0.0.1' || $ip === '::1') {
-            return '本地';
-        }
-
-        $cfCountry = (string)request()->header('CF-IPCountry', '');
-        if ($cfCountry !== '' && strtoupper($cfCountry) !== 'XX') {
-            return strtoupper($cfCountry);
-        }
-
-        try {
-            $url = 'http://ip-api.com/json/' . urlencode($ip) . '?fields=status,country,regionName,city&lang=zh-CN';
-            $ctx = stream_context_create([
-                'http' => [
-                    'timeout' => 1.5,
-                    'method' => 'GET',
-                ],
-            ]);
-            $raw = @file_get_contents($url, false, $ctx);
-            if ($raw === false) {
-                return '';
-            }
-            $data = json_decode($raw, true);
-            if (!is_array($data) || ($data['status'] ?? '') !== 'success') {
-                return '';
-            }
-            $parts = array_filter([
-                $data['country'] ?? '',
-                $data['regionName'] ?? '',
-                $data['city'] ?? '',
-            ]);
-            return implode(' ', $parts);
-        } catch (\Throwable $e) {
-            return '';
-        }
-    }
-
-    public static function parseBrowser(string $ua): string
-    {
-        if ($ua === '') {
-            return '未知';
-        }
-
-        $map = [
-            'Edg' => 'Edge',
-            'Edge' => 'Edge',
-            'OPR' => 'Opera',
-            'Opera' => 'Opera',
-            'Chrome' => 'Chrome',
-            'Firefox' => 'Firefox',
-            'Safari' => 'Safari',
-            'MSIE' => 'IE',
-            'Trident' => 'IE',
-            'MicroMessenger' => '微信',
-            'QQBrowser' => 'QQ浏览器',
-            'UCBrowser' => 'UC浏览器',
-            'Telegram' => 'Telegram',
-        ];
-
-        foreach ($map as $needle => $name) {
-            if (stripos($ua, $needle) === false) {
-                continue;
-            }
-            if ($needle === 'Safari' && stripos($ua, 'Chrome') !== false) {
-                continue;
-            }
-            if ($needle === 'Chrome' && (stripos($ua, 'Edg') !== false || stripos($ua, 'OPR') !== false)) {
-                continue;
-            }
-            $version = '';
-            if (preg_match('/' . preg_quote($needle, '/') . '[\/\s]?([\d.]+)/i', $ua, $m)) {
-                $version = $m[1];
-            } elseif ($needle === 'Trident' && preg_match('/rv:([\d.]+)/i', $ua, $m)) {
-                $version = $m[1];
-            }
-            return $version !== '' ? "{$name}({$version})" : $name;
-        }
-
-        return '未知浏览器';
-    }
-
-    public static function parseOs(string $ua): string
-    {
-        if ($ua === '') {
-            return '未知';
-        }
-
-        $rules = [
-            '/windows nt 10/i' => 'Windows 10/11',
-            '/windows nt 6\.3/i' => 'Windows 8.1',
-            '/windows nt 6\.2/i' => 'Windows 8',
-            '/windows nt 6\.1/i' => 'Windows 7',
-            '/windows nt 6\.0/i' => 'Windows Vista',
-            '/windows nt 5\.1|windows xp/i' => 'Windows XP',
-            '/windows phone/i' => 'Windows Phone',
-            '/android/i' => 'Android',
-            '/iphone|ipad|ipod/i' => 'iOS',
-            '/mac os x/i' => 'Mac OS',
-            '/macintosh/i' => 'Mac OS',
-            '/linux/i' => 'Linux',
-            '/ubuntu/i' => 'Ubuntu',
-            '/cros/i' => 'Chrome OS',
-        ];
-
-        foreach ($rules as $pattern => $name) {
-            if (preg_match($pattern, $ua)) {
-                if ($name === 'Android' && preg_match('/Android\s+([\d.]+)/i', $ua, $m)) {
-                    return 'Android ' . $m[1];
-                }
-                if ($name === 'iOS' && preg_match('/OS\s+([\d_]+)/i', $ua, $m)) {
-                    return 'iOS ' . str_replace('_', '.', $m[1]);
-                }
-                if ($name === 'Mac OS' && preg_match('/Mac OS X\s+([\d_]+)/i', $ua, $m)) {
-                    return 'Mac OS ' . str_replace('_', '.', $m[1]);
-                }
-                return $name;
-            }
-        }
-
-        return '未知操作系统';
-    }
-}

+ 12 - 33
app/Http/Controllers/admin/User.php

@@ -492,9 +492,9 @@ class User extends Controller
     /**
      * 用户登录日志
      *
-     * 返回字段:user_id/member_id、first_name、login_ip、country(国家/地区)
-     * browser(浏览器)、os(操作系统)、status/status_text(登录成功或失败)
-     * created_at(登录时间)、logout_time(登出时间)、online_duration(在线时长分钟)
+     * 字段:user_id、login_account、login_ip、country、browser、os
+     * status/status_text、created_at、logout_time、online_duration(登出后落库)
+     * is_online、online_duration_live(未登出时估算分钟数)
      */
     public function loginLog()
     {
@@ -509,15 +509,15 @@ class User extends Controller
                 'start_time' => ['nullable', 'date', 'date_format:Y-m-d', 'required_with:end_time'],
                 'end_time' => ['nullable', 'date', 'date_format:Y-m-d', 'required_with:start_time'],
             ]);
-            $page = request()->input('page', 1);
-            $limit = request()->input('limit', 15);
+            $page = (int)request()->input('page', 1);
+            $limit = (int)request()->input('limit', 15);
 
-            // leftJoin:失败日志也可能没有对应用户,仍需展示日志本身
             $query = UserLogin::query()
                 ->leftJoin('users', 'user_login.user_id', '=', 'users.user_id')
                 ->select([
                     'user_login.id',
                     'user_login.user_id',
+                    'user_login.login_account',
                     'user_login.login_ip',
                     'user_login.country',
                     'user_login.browser',
@@ -543,46 +543,25 @@ class User extends Controller
             if (!empty($params['first_name'])) {
                 $query->where('users.first_name', 'like', "%{$params['first_name']}%");
             }
-            if (isset($params['status']) && $params['status'] !== '' && $params['status'] !== null) {
+            if (array_key_exists('status', $params) && $params['status'] !== null) {
                 $query->where('user_login.status', (int)$params['status']);
             }
             if (!empty($params['start_time'])) {
-                $startTime = $params['start_time'] . " 00:00:00";
-                $query->where('user_login.created_at', '>=', $startTime);
+                $query->where('user_login.created_at', '>=', $params['start_time'] . ' 00:00:00');
             }
             if (!empty($params['end_time'])) {
-                $endTime = $params['end_time'] . " 23:59:59";
-                $query->where('user_login.created_at', '<=', $endTime);
+                $query->where('user_login.created_at', '<=', $params['end_time'] . ' 23:59:59');
             }
+
             $count = (clone $query)->count();
             $list = $query
                 ->forPage($page, $limit)
                 ->orderByDesc('user_login.id')
-                ->get()
-                ->map(function ($item) {
-                    // 未登出时:若仍在线可按当前时间估算在线时长(仅展示,不写库)
-                    if (
-                        (int)$item->status === UserLogin::STATUS_SUCCESS
-                        && empty($item->getRawOriginal('logout_time'))
-                        && empty($item->online_duration)
-                        && !empty($item->getRawOriginal('created_at'))
-                    ) {
-                        $loginAt = strtotime($item->getRawOriginal('created_at'));
-                        if ($loginAt) {
-                            $item->online_duration = (int)ceil(max(0, time() - $loginAt) / 60);
-                        }
-                    }
-                    // 国家/地区等空值统一给前端可展示的占位
-                    $item->country = $item->country ?: '';
-                    $item->browser = $item->browser ?: '';
-                    $item->os = $item->os ?: '';
-                    return $item;
-                });
+                ->get();
         } catch (Exception $e) {
-            return $this->error(HttpStatus::CUSTOM_ERROR,$e->getMessage());
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
         }
         return $this->success(['total' => $count, 'data' => $list]);
-
     }
 
     function setRechargeChannelGroup()

+ 48 - 48
app/Models/UserLogin.php

@@ -2,15 +2,23 @@
 
 namespace App\Models;
 
-use App\Helpers\LoginClientInfo;
 use Carbon\Carbon;
 
+/**
+ * 用户登录日志(只读侧:bot-28 后台查询)
+ *
+ * 写入在 melbet_sport-api 完成。
+ * - user_id:仅真实用户 ID,失败且无用户时为空字符串
+ * - login_account:尝试登录账号
+ * - online_duration:仅登出后落库;未登出时接口通过 online_duration_live 估算
+ */
 class UserLogin extends BaseModel
 {
     protected $table = 'user_login';
 
     protected $fillable = [
         'user_id',
+        'login_account',
         'login_ip',
         'country',
         'browser',
@@ -21,70 +29,62 @@ class UserLogin extends BaseModel
         'user_agent',
     ];
 
-    // 登录日志需要展示创建时间(登录时间
+    // 登录日志需要展示登录时间
     protected $hidden = [];
 
-    protected $appends = ['status_text'];
+    protected $appends = ['status_text', 'is_online', 'online_duration_live'];
 
     public const STATUS_FAIL = 0;
     public const STATUS_SUCCESS = 1;
 
-    /**
-     * 写入登录日志
-     *
-     * @param string|int $userId 用户ID(member_id / user_id)
-     * @param int $status 1成功 0失败
-     * @param string|null $loginIp
-     * @param string|null $userAgent
-     */
-    public static function addData($userId, int $status = self::STATUS_SUCCESS, ?string $loginIp = null, ?string $userAgent = null): self
+    public function getStatusTextAttribute(): string
+    {
+        return (int)($this->attributes['status'] ?? self::STATUS_FAIL) === self::STATUS_SUCCESS
+            ? '登录成功'
+            : '登录失败';
+    }
+
+    public function getIsOnlineAttribute(): bool
     {
-        $info = LoginClientInfo::fromRequest($loginIp, $userAgent);
-
-        return self::create([
-            'user_id' => (string)$userId,
-            'login_ip' => $info['login_ip'],
-            'country' => $info['country'],
-            'browser' => $info['browser'],
-            'os' => $info['os'],
-            'status' => $status,
-            'logout_time' => null,
-            'online_duration' => null,
-            'user_agent' => $info['user_agent'],
-        ]);
+        if ((int)($this->attributes['status'] ?? self::STATUS_FAIL) !== self::STATUS_SUCCESS) {
+            return false;
+        }
+        $logout = $this->attributes['logout_time'] ?? null;
+        return empty($logout) || $logout === '0000-00-00 00:00:00';
     }
 
     /**
-     * 标记登出:更新最近一条未登出的成功登录记录
+     * 展示用在线时长(分钟):
+     * - 已登出:库内 online_duration
+     * - 仍在线:按登录时间估算(不写库)
+     * - 失败:null
      */
-    public static function markLogout($userId): void
+    public function getOnlineDurationLiveAttribute(): ?int
     {
-        $log = self::where('user_id', (string)$userId)
-            ->where('status', self::STATUS_SUCCESS)
-            ->whereNull('logout_time')
-            ->orderByDesc('id')
-            ->first();
+        if ((int)($this->attributes['status'] ?? self::STATUS_FAIL) !== self::STATUS_SUCCESS) {
+            return null;
+        }
 
-        if (!$log) {
-            return;
+        $stored = $this->attributes['online_duration'] ?? null;
+        if ($stored !== null && $stored !== '') {
+            return (int)$stored;
         }
 
-        $logoutAt = Carbon::now('Asia/Shanghai');
-        $loginAt = Carbon::parse($log->getRawOriginal('created_at') ?? $log->created_at)
-            ->setTimezone('Asia/Shanghai');
+        if (!$this->is_online) {
+            return null;
+        }
 
-        $seconds = max(0, $logoutAt->getTimestamp() - $loginAt->getTimestamp());
-        $minutes = (int)ceil($seconds / 60);
+        $rawCreated = $this->attributes['created_at'] ?? null;
+        if (empty($rawCreated)) {
+            return null;
+        }
 
-        $log->logout_time = $logoutAt->format('Y-m-d H:i:s');
-        $log->online_duration = $minutes;
-        $log->save();
-    }
+        $loginTs = strtotime((string)$rawCreated);
+        if (!$loginTs) {
+            return null;
+        }
 
-    public function getStatusTextAttribute(): string
-    {
-        $status = (int)($this->attributes['status'] ?? self::STATUS_SUCCESS);
-        return $status === self::STATUS_SUCCESS ? '登录成功' : '登录失败';
+        return (int)ceil(max(0, time() - $loginTs) / 60);
     }
 
     public function getLogoutTimeAttribute($value): ?string
@@ -95,7 +95,7 @@ class UserLogin extends BaseModel
         return Carbon::parse($value)->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
     }
 
-    // 获取用户未登录天数(最后第二次未登录的天数)
+    // 获取用户未登录天数(最后第二次登录间隔天数)
     public static function getNotLoginDays($memberId)
     {
         $list = self::where('user_id', $memberId)

+ 4 - 4
database/migrations/2026_08_04_120000_add_fields_to_user_login.sql

@@ -1,9 +1,9 @@
--- 用户登录日志补充字段
--- 若列已存在会报错,可忽略对应语句
+-- 用户登录日志补充字段(可直接在库执行;列已存在则跳过对应语句)
+ALTER TABLE `bot_user_login` ADD COLUMN `login_account` varchar(64) NOT NULL DEFAULT '' COMMENT '尝试登录的账号' AFTER `user_id`;
 ALTER TABLE `bot_user_login` ADD COLUMN `country` varchar(128) NOT NULL DEFAULT '' COMMENT '国家/地区' AFTER `login_ip`;
 ALTER TABLE `bot_user_login` ADD COLUMN `browser` varchar(128) NOT NULL DEFAULT '' COMMENT '浏览器' AFTER `country`;
 ALTER TABLE `bot_user_login` ADD COLUMN `os` varchar(128) NOT NULL DEFAULT '' COMMENT '操作系统' AFTER `browser`;
 ALTER TABLE `bot_user_login` ADD COLUMN `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:1登录成功 0登录失败' AFTER `os`;
 ALTER TABLE `bot_user_login` ADD COLUMN `logout_time` datetime NULL DEFAULT NULL COMMENT '登出时间' AFTER `status`;
-ALTER TABLE `bot_user_login` ADD COLUMN `online_duration` int unsigned NULL DEFAULT NULL COMMENT '在线时长(分钟)' AFTER `logout_time`;
-ALTER TABLE `bot_user_login` ADD COLUMN `user_agent` varchar(512) NOT NULL DEFAULT '' COMMENT 'User-Agent 原始字符串' AFTER `online_duration`;
+ALTER TABLE `bot_user_login` ADD COLUMN `online_duration` int unsigned NULL DEFAULT NULL COMMENT '在线时长(分钟,仅登出后写入)' AFTER `logout_time`;
+ALTER TABLE `bot_user_login` ADD COLUMN `user_agent` varchar(512) NOT NULL DEFAULT '' COMMENT 'User-Agent' AFTER `online_duration`;

+ 12 - 7
database/migrations/2026_08_04_120000_add_fields_to_user_login_table.php

@@ -12,6 +12,9 @@ return new class extends Migration {
         }
 
         Schema::table('user_login', function (Blueprint $table) {
+            if (!Schema::hasColumn('user_login', 'login_account')) {
+                $table->string('login_account', 64)->default('')->comment('尝试登录的账号(失败时可能无 user_id)')->after('user_id');
+            }
             if (!Schema::hasColumn('user_login', 'country')) {
                 $table->string('country', 128)->default('')->comment('国家/地区')->after('login_ip');
             }
@@ -28,7 +31,7 @@ return new class extends Migration {
                 $table->dateTime('logout_time')->nullable()->comment('登出时间')->after('status');
             }
             if (!Schema::hasColumn('user_login', 'online_duration')) {
-                $table->unsignedInteger('online_duration')->nullable()->comment('在线时长(分钟)')->after('logout_time');
+                $table->unsignedInteger('online_duration')->nullable()->comment('在线时长(分钟,仅登出后写入)')->after('logout_time');
             }
             if (!Schema::hasColumn('user_login', 'user_agent')) {
                 $table->string('user_agent', 512)->default('')->comment('User-Agent 原始字符串')->after('online_duration');
@@ -42,12 +45,14 @@ return new class extends Migration {
             return;
         }
 
-        Schema::table('user_login', function (Blueprint $table) {
-            foreach (['country', 'browser', 'os', 'status', 'logout_time', 'online_duration', 'user_agent'] as $column) {
-                if (Schema::hasColumn('user_login', $column)) {
-                    $table->dropColumn($column);
-                }
-            }
+        $columns = ['login_account', 'country', 'browser', 'os', 'status', 'logout_time', 'online_duration', 'user_agent'];
+        $existing = array_values(array_filter($columns, fn ($col) => Schema::hasColumn('user_login', $col)));
+        if ($existing === []) {
+            return;
+        }
+
+        Schema::table('user_login', function (Blueprint $table) use ($existing) {
+            $table->dropColumn($existing);
         });
     }
 };

+ 32 - 0
database/migrations/2026_08_04_130000_add_login_account_to_user_login_table.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 兼容已执行过 2026_08_04_120000 但尚未含 login_account 的环境
+ */
+return new class extends Migration {
+    public function up()
+    {
+        if (!Schema::hasTable('user_login') || Schema::hasColumn('user_login', 'login_account')) {
+            return;
+        }
+
+        Schema::table('user_login', function (Blueprint $table) {
+            $table->string('login_account', 64)->default('')->comment('尝试登录的账号')->after('user_id');
+        });
+    }
+
+    public function down()
+    {
+        if (!Schema::hasTable('user_login') || !Schema::hasColumn('user_login', 'login_account')) {
+            return;
+        }
+
+        Schema::table('user_login', function (Blueprint $table) {
+            $table->dropColumn('login_account');
+        });
+    }
+};