doge 13 часов назад
Родитель
Сommit
72abc668e4

+ 152 - 0
app/Helpers/LoginClientInfo.php

@@ -0,0 +1,152 @@
+<?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 '未知操作系统';
+    }
+}

+ 57 - 9
app/Http/Controllers/admin/User.php

@@ -491,6 +491,10 @@ class User extends Controller
 
     /**
      * 用户登录日志
+     *
+     * 返回字段:user_id/member_id、first_name、login_ip、country(国家/地区)、
+     * browser(浏览器)、os(操作系统)、status/status_text(登录成功或失败)、
+     * created_at(登录时间)、logout_time(登出时间)、online_duration(在线时长分钟)
      */
     public function loginLog()
     {
@@ -501,35 +505,79 @@ class User extends Controller
                 'user_id' => ['nullable'],
                 'member_id' => ['nullable'],
                 'first_name' => ['nullable'],
+                'status' => ['nullable', 'integer', 'in:0,1'],
                 '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);
 
-            $query = UserLogin::join('users', 'user_login.user_id', '=', 'users.user_id');
+            // leftJoin:失败日志也可能没有对应用户,仍需展示日志本身
+            $query = UserLogin::query()
+                ->leftJoin('users', 'user_login.user_id', '=', 'users.user_id')
+                ->select([
+                    'user_login.id',
+                    'user_login.user_id',
+                    'user_login.login_ip',
+                    'user_login.country',
+                    'user_login.browser',
+                    'user_login.os',
+                    'user_login.status',
+                    'user_login.logout_time',
+                    'user_login.online_duration',
+                    'user_login.user_agent',
+                    'user_login.created_at',
+                    'user_login.updated_at',
+                    'users.member_id',
+                    'users.first_name',
+                    'users.account',
+                    'users.username',
+                ]);
+
             if (!empty($params['user_id'])) {
-                $query = $query->where('user_login.user_id', $params['user_id']);
+                $query->where('user_login.user_id', $params['user_id']);
             }
             if (!empty($params['member_id'])) {
-                $query = $query->where('user_login.user_id', $params['member_id']);
+                $query->where('user_login.user_id', $params['member_id']);
             }
             if (!empty($params['first_name'])) {
-                $query = $query->where('users.first_name', 'like', "%{$params['first_name']}%");
+                $query->where('users.first_name', 'like', "%{$params['first_name']}%");
+            }
+            if (isset($params['status']) && $params['status'] !== '' && $params['status'] !== null) {
+                $query->where('user_login.status', (int)$params['status']);
             }
             if (!empty($params['start_time'])) {
                 $startTime = $params['start_time'] . " 00:00:00";
-                $query = $query->where('user_login.created_at', '>=', $startTime);
+                $query->where('user_login.created_at', '>=', $startTime);
             }
             if (!empty($params['end_time'])) {
                 $endTime = $params['end_time'] . " 23:59:59";
-                $query = $query->where('user_login.updated_at', '<=', $endTime);
+                $query->where('user_login.created_at', '<=', $endTime);
             }
-            $count = $query->count();
+            $count = (clone $query)->count();
             $list = $query
                 ->forPage($page, $limit)
-                ->orderByDesc('user_login.created_at')
-                ->get();
+                ->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;
+                });
         } catch (Exception $e) {
             return $this->error(HttpStatus::CUSTOM_ERROR,$e->getMessage());
         }

+ 94 - 2
app/Models/UserLogin.php

@@ -2,16 +2,108 @@
 
 namespace App\Models;
 
+use App\Helpers\LoginClientInfo;
+use Carbon\Carbon;
+
 class UserLogin extends BaseModel
 {
     protected $table = 'user_login';
-    protected $fillable = ['user_id', 'login_ip'];
+
+    protected $fillable = [
+        'user_id',
+        'login_ip',
+        'country',
+        'browser',
+        'os',
+        'status',
+        'logout_time',
+        'online_duration',
+        'user_agent',
+    ];
+
+    // 登录日志需要展示创建时间(登录时间)
     protected $hidden = [];
 
+    protected $appends = ['status_text'];
+
+    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
+    {
+        $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'],
+        ]);
+    }
+
+    /**
+     * 标记登出:更新最近一条未登出的成功登录记录
+     */
+    public static function markLogout($userId): void
+    {
+        $log = self::where('user_id', (string)$userId)
+            ->where('status', self::STATUS_SUCCESS)
+            ->whereNull('logout_time')
+            ->orderByDesc('id')
+            ->first();
+
+        if (!$log) {
+            return;
+        }
+
+        $logoutAt = Carbon::now('Asia/Shanghai');
+        $loginAt = Carbon::parse($log->getRawOriginal('created_at') ?? $log->created_at)
+            ->setTimezone('Asia/Shanghai');
+
+        $seconds = max(0, $logoutAt->getTimestamp() - $loginAt->getTimestamp());
+        $minutes = (int)ceil($seconds / 60);
+
+        $log->logout_time = $logoutAt->format('Y-m-d H:i:s');
+        $log->online_duration = $minutes;
+        $log->save();
+    }
+
+    public function getStatusTextAttribute(): string
+    {
+        $status = (int)($this->attributes['status'] ?? self::STATUS_SUCCESS);
+        return $status === self::STATUS_SUCCESS ? '登录成功' : '登录失败';
+    }
+
+    public function getLogoutTimeAttribute($value): ?string
+    {
+        if (empty($value) || $value === '0000-00-00 00:00:00') {
+            return null;
+        }
+        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)->orderByDesc('id')->limit(2)->get()->toArray();
+        $list = self::where('user_id', $memberId)
+            ->where('status', self::STATUS_SUCCESS)
+            ->orderByDesc('id')
+            ->limit(2)
+            ->get()
+            ->toArray();
         if (count($list) < 2) {
             return 0;
         }

+ 9 - 0
database/migrations/2026_08_04_120000_add_fields_to_user_login.sql

@@ -0,0 +1,9 @@
+-- 用户登录日志补充字段
+-- 若列已存在会报错,可忽略对应语句
+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`;

+ 53 - 0
database/migrations/2026_08_04_120000_add_fields_to_user_login_table.php

@@ -0,0 +1,53 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up()
+    {
+        if (!Schema::hasTable('user_login')) {
+            return;
+        }
+
+        Schema::table('user_login', function (Blueprint $table) {
+            if (!Schema::hasColumn('user_login', 'country')) {
+                $table->string('country', 128)->default('')->comment('国家/地区')->after('login_ip');
+            }
+            if (!Schema::hasColumn('user_login', 'browser')) {
+                $table->string('browser', 128)->default('')->comment('浏览器')->after('country');
+            }
+            if (!Schema::hasColumn('user_login', 'os')) {
+                $table->string('os', 128)->default('')->comment('操作系统')->after('browser');
+            }
+            if (!Schema::hasColumn('user_login', 'status')) {
+                $table->tinyInteger('status')->default(1)->comment('状态:1登录成功 0登录失败')->after('os');
+            }
+            if (!Schema::hasColumn('user_login', 'logout_time')) {
+                $table->dateTime('logout_time')->nullable()->comment('登出时间')->after('status');
+            }
+            if (!Schema::hasColumn('user_login', 'online_duration')) {
+                $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');
+            }
+        });
+    }
+
+    public function down()
+    {
+        if (!Schema::hasTable('user_login')) {
+            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);
+                }
+            }
+        });
+    }
+};