1
0
Эх сурвалжийг харах

feat: online log per user-ip

Irohaede 2 жил өмнө
parent
commit
9b26c20925

+ 79 - 0
db/migrations/2023032600-online_log_per_user-ip.php

@@ -0,0 +1,79 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Interfaces\MigrationInterface;
+use App\Services\DB;
+
+return new class() implements MigrationInterface {
+    public function up(): int
+    {
+        $pdo = DB::getPdo();
+        $pdo->exec('
+            CREATE TABLE online_log (
+                id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+                user_id INT UNSIGNED NOT NULL,
+                ip INET6 NOT NULL,
+                node_id INT UNSIGNED NOT NULL,
+                first_time INT UNSIGNED NOT NULL,
+                last_time INT UNSIGNED NOT NULL,
+                PRIMARY KEY (id),
+                UNIQUE KEY (user_id, ip)
+            )
+        ');
+
+        $pdo->exec('
+            INSERT INTO online_log (user_id, ip, node_id, first_time, last_time)
+                SELECT
+                    userid,
+                    CASE
+                        WHEN IS_IPV4(ip) = 1 THEN CONCAT("::ffff:", ip)
+                        WHEN IS_IPV6(ip) = 1 THEN ip
+                        ELSE NULL
+                    END AS new_ip,
+                    nodeid,
+                    MIN(datetime) AS first_time,
+                    MAX(datetime) AS last_time
+                FROM
+                    alive_ip
+                WHERE
+                    userid IS NOT NULL
+                    AND nodeid IS NOT NULL
+                    AND datetime IS NOT NULL
+                GROUP BY
+                    userid, ip
+                HAVING
+                    new_ip IS NOT NULL
+        ');
+
+        $pdo->exec('DROP TABLE alive_ip');
+
+        return 2023032500;
+    }
+
+    public function down(): int
+    {
+        $pdo = DB::getPdo();
+        $pdo->exec('
+            CREATE TABLE alive_ip (
+                id BIGINT(20) NOT NULL AUTO_INCREMENT,
+                nodeid INT(11) DEFAULT NULL,
+                userid INT(11) DEFAULT NULL,
+                ip VARCHAR(255) DEFAULT NULL,
+                datetime BIGINT(20) DEFAULT NULL,
+                PRIMARY KEY (id)
+            ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+        ');
+
+        $pdo->exec('
+            INSERT INTO alive_ip (nodeid, userid, ip, datetime)
+                SELECT node_id, user_id, ip, first_time AS datetime FROM online_log
+                UNION
+                SELECT node_id, user_id, ip, last_time AS datetime FROM online_log
+        ');
+
+        $pdo->exec('DROP TABLE online_log');
+
+        return 2023031701;
+    }
+};

+ 2 - 2
src/Command/Job.php

@@ -10,8 +10,8 @@ use App\Models\DetectBanLog;
 use App\Models\DetectLog;
 use App\Models\EmailQueue;
 use App\Models\EmailVerify;
-use App\Models\Ip;
 use App\Models\Node;
+use App\Models\OnlineLog;
 use App\Models\PasswordReset;
 use App\Models\Setting;
 use App\Models\Shop;
@@ -76,7 +76,7 @@ EOL;
         EmailVerify::where('expire_in', '<', time() - 86400 * 3)->delete();
         EmailQueue::where('time', '<', time() - 86400 * 3)->delete();
         PasswordReset::where('expire_time', '<', time() - 86400 * 3)->delete();
-        Ip::where('datetime', '<', time() - 300)->delete();
+        OnlineLog::where('last_time', '<', time() - 86400 * 30)->delete();
         StreamMedia::where('created_at', '<', time() - 86400 * 3)->delete();
         TelegramSession::where('datetime', '<', time() - 900)->delete();
         // ------- 清理各表记录

+ 35 - 23
src/Controllers/Admin/IpController.php

@@ -5,15 +5,15 @@ declare(strict_types=1);
 namespace App\Controllers\Admin;
 
 use App\Controllers\BaseController;
-use App\Models\Ip;
 use App\Models\LoginIp;
+use App\Services\DB;
 use App\Utils\Tools;
-use Exception;
 use Psr\Http\Message\ResponseInterface;
 use Slim\Http\Response;
 use Slim\Http\ServerRequest;
+use function array_map;
+use function array_slice;
 use function count;
-use function time;
 
 final class IpController extends BaseController
 {
@@ -40,14 +40,13 @@ final class IpController extends BaseController
             'node_name' => '节点名',
             'ip' => 'IP',
             'location' => 'IP归属地',
-            'datetime' => '时间',
+            'first_time' => '首次连接',
+            'first_time' => '最后连接',
         ],
     ];
 
     /**
      * 后台登录记录页面
-     *
-     * @throws Exception
      */
     public function login(ServerRequest $request, Response $response, array $args): Response|ResponseInterface
     {
@@ -87,8 +86,6 @@ final class IpController extends BaseController
 
     /**
      * 后台在线 IP 页面
-     *
-     * @throws Exception
      */
     public function alive(ServerRequest $request, Response $response, array $args): Response|ResponseInterface
     {
@@ -104,26 +101,41 @@ final class IpController extends BaseController
      */
     public function ajaxAlive(ServerRequest $request, Response $response, array $args): Response|ResponseInterface
     {
-        $length = $request->getParam('length');
-        $page = $request->getParam('start') / $length + 1;
-        $draw = $request->getParam('draw');
+        $data = $request->getParsedBody();
+        $length = (int) ($data['length'] ?? 0);
+        $start = (int) ($data['start'] ?? 0);
+        $draw = $data['draw'] ?? null;
 
-        $alives = Ip::where('datetime', '>=', time() - 60)->orderBy('id', 'desc')->paginate($length, '*', '', $page);
-        $total = count(Ip::where('datetime', '>=', time() - 60)->orderBy('id', 'desc')->get());
+        $logs = DB::select('
+            SELECT
+                user.user_name,
+                online_log.ip,
+                node.name AS node_name,
+                online_log.first_time,
+                online_log.last_time
+            FROM
+                online_log
+                LEFT JOIN user ON user.id = online_log.user_id
+                LEFT JOIN node ON node.id = online_log.node_id
+        ');
 
-        foreach ($alives as $alive) {
-            $alive->user_name = $alive->userName();
-            $alive->node_name = $alive->nodeName();
-            $alive->ip = Tools::getRealIp($alive->ip);
-            $alive->location = Tools::getIpLocation($alive->ip);
-            $alive->datetime = Tools::toDateTime((int) $alive->datetime);
-        }
+        $count = count($logs);
+        $data = array_map(static function ($val) {
+            return [
+                'user_name' => $val->user_name,
+                'ip' => $val->ip,
+                'node_name' => $val->node_name,
+                'location' => Tools::getIpLocation($val->ip),
+                'first_time' => Tools::toDateTime($val->first_time),
+                'last_time' => Tools::toDateTime($val->last_time),
+            ];
+        }, array_slice($logs, $start, $length));
 
         return $response->withJson([
             'draw' => $draw,
-            'recordsTotal' => $total,
-            'recordsFiltered' => $total,
-            'alives' => $alives,
+            'recordsTotal' => $count,
+            'recordsFiltered' => $count,
+            'alives' => $data,
         ]);
     }
 }

+ 63 - 37
src/Controllers/WebAPI/UserController.php

@@ -6,22 +6,20 @@ namespace App\Controllers\WebAPI;
 
 use App\Controllers\BaseController;
 use App\Models\DetectLog;
-use App\Models\Ip;
 use App\Models\Node;
-use App\Models\User;
 use App\Services\DB;
 use App\Utils\ResponseHelper;
-use App\Utils\Tools;
-use Illuminate\Database\Eloquent\Builder;
 use Psr\Http\Message\ResponseInterface;
 use Slim\Http\Response;
 use Slim\Http\ServerRequest;
 use function count;
-use function in_array;
+use function filter_var;
 use function is_array;
 use function json_decode;
-use function strval;
 use function time;
+use const FILTER_FLAG_IPV4;
+use const FILTER_FLAG_IPV6;
+use const FILTER_VALIDATE_IP;
 
 final class UserController extends BaseController
 {
@@ -53,32 +51,49 @@ final class UserController extends BaseController
             ]);
         }
 
-        $users_raw = User::where('is_banned', 0)
-            ->where('expire_in', '>', date('Y-m-d H:i:s'))
-            ->where(static function (Builder $query) use ($node): void {
-                $query->whereRaw(
-                    'class >= ? AND IF(? = 0, 1, node_group = ?)',
-                    [$node->node_class, $node->node_group, $node->node_group]
-                )->orWhere('is_admin', 1);
-            })
-            ->get();
-
-        if (in_array($node->sort, [11, 14])) {
-            $key_list = [
-                'id', 'node_connector', 'node_speedlimit', 'node_iplimit', 'uuid', 'alive_ip',
-            ];
-        } else {
-            $key_list = [
-                'id', 'node_connector', 'node_speedlimit', 'node_iplimit', 'method', 'port', 'passwd', 'alive_ip',
-            ];
-        }
+        $users_raw = DB::select('
+            SELECT
+                user.id,
+                user.u,
+                user.d,
+                user.transfer_enable,
+                user.node_connector,
+                user.node_speedlimit,
+                user.node_iplimit,
+                user.method,
+                user.port,
+                user.passwd,
+                user.uuid,
+                IF(online_log.count IS NULL, 0, online_log.count) AS alive_ip
+            FROM
+                user LEFT JOIN (
+                    SELECT
+                        user_id, COUNT(*) AS count
+                    FROM
+                        online_log
+                    WHERE
+                        last_time > UNIX_TIMESTAMP() - 90
+                    GROUP BY
+                        user_id
+                ) ON online_log.user_id = user.id
+            WHERE
+                user.is_banned = 0
+                AND user.expire_in > CURRENT_TIMESTAMP()
+                AND (
+                    (
+                        user.class >= ?
+                        AND IF(? = 0, 1, user.node_group = ?)
+                    ) OR user.is_admin = 1
+                )
+        ');
+
+        $keys_unset = match ($node->sort) {
+            11 || 14 => ['u', 'd', 'transfer_enable', 'method', 'port', 'passwd'],
+            default => ['u', 'd', 'transfer_enable', 'uuid']
+        };
 
-        $alive_ip = (new Ip())->getUserAliveIpCount();
         $users = [];
         foreach ($users_raw as $user_raw) {
-            if (isset($alive_ip[strval($user_raw->id)]) && $user_raw->node_connector !== 0 && $user_raw->node_iplimit !== 0) {
-                $user_raw->alive_ip = $alive_ip[strval($user_raw->id)];
-            }
             if ($user_raw->transfer_enable <= $user_raw->u + $user_raw->d) {
                 if ($_ENV['keep_connect'] === true) {
                     // 流量耗尽用户限速至 1Mbps
@@ -88,7 +103,9 @@ final class UserController extends BaseController
                 }
             }
 
-            $user_raw = Tools::keyFilter($user_raw, $key_list);
+            foreach ($keys_unset as $key) {
+                unset($user_raw->$key);
+            }
             $users[] = $user_raw;
         }
 
@@ -179,16 +196,25 @@ final class UserController extends BaseController
             ]);
         }
 
+        $stat = DB::getPdo()->prepare('
+            INSERT INTO online_log (user_id, ip, node_id, first_time, last_time)
+                VALUES (?, ?, ?, UNIX_TIMESTAMP(), UNIX_TIMESTAMP())
+                ON DUPLICATE KEY UPDATE node_id = ?, last_time = UNIX_TIMESTAMP()
+        ');
+
         foreach ($data as $log) {
             $ip = (string) $log?->ip;
-            $userid = (int) $log?->user_id;
+            $user_id = (int) $log?->user_id;
 
-            Ip::insert([
-                'userid' => $userid,
-                'nodeid' => $node_id,
-                'ip' => $ip,
-                'datetime' => time(),
-            ]);
+            if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
+                // convert IPv4 Address to IPv4-mapped IPv6 Address
+                $ip = "::ffff:{$ip}";
+            } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) {
+                // either IPv4 or IPv6 Address
+                continue;
+            }
+
+            $stat->execute([$user_id, $ip, $node_id, $node_id]);
         }
 
         return $response->withJson([

+ 0 - 78
src/Models/Ip.php

@@ -1,78 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace App\Models;
-
-use App\Services\DB;
-use function strval;
-
-/**
- * Ip Model
- */
-final class Ip extends Model
-{
-    protected $connection = 'default';
-    protected $table = 'alive_ip';
-
-    /**
-     * 用户
-     */
-    public function user(): ?User
-    {
-        return User::find($this->userid);
-    }
-
-    /**
-     * 用户名
-     */
-    public function userName(): string
-    {
-        if ($this->user() === null) {
-            return '用户已不存在';
-        }
-        return $this->user()->user_name;
-    }
-
-    /**
-     * 节点
-     */
-    public function node(): ?Node
-    {
-        return Node::find($this->nodeid);
-    }
-
-    /**
-     * 节点名
-     */
-    public function nodeName(): string
-    {
-        if ($this->node() === null) {
-            return '节点已不存在';
-        }
-        return $this->node()->name;
-    }
-
-    /**
-     * 时间
-     */
-    public function datetime(): string
-    {
-        return date('Y-m-d H:i:s', $this->datetime);
-    }
-
-    public function getUserAliveIpCount(): array
-    {
-        $pdo = DB::getPdo();
-        $res = [];
-        foreach ($pdo->query('SELECT `userid`, COUNT(DISTINCT `ip`) AS `count` FROM `alive_ip` WHERE `datetime` >= UNIX_TIMESTAMP(NOW()) - 60 GROUP BY `userid`') as $line) {
-            $res[strval($line['userid'])] = $line['count'];
-        }
-        return $res;
-    }
-
-    public function ip(): array|string
-    {
-        return str_replace('::ffff:', '', $this->attributes['ip']);
-    }
-}

+ 45 - 0
src/Models/OnlineLog.php

@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use function substr;
+
+/**
+ * Online Log
+ *
+ * @property int    $id         INT UNSIGNED NOT NULL AUTO_INCREMENT, PRIMARY KEY
+ * @property int    $user_id    INT UNSIGNED NOT NULL, UNIQUE KEY(A0)
+ * @property string $ip         INET6 NOT NULL, UNIQUE KEY(A1) \
+ *      Human readable IPv6 address. \
+ *      IPv4 Address would be IPv4-mapped IPv6 Address like `::ffff:1.1.1.1`.
+ * @property int    $node_id    INT UNSIGNED NOT NULL
+ * @property int    $first_time INT UNSIGNED NOT NULL \
+ *      The time when $ip fisrt time connect.
+ * @property int    $last_time  INT UNSIGNED NOT NULL \
+ *      The time when $ip last time connect.
+ *
+ * @see https://mariadb.com/kb/en/inet6/ MariaDB INET6 data type
+ * @see https://www.rfc-editor.org/rfc/rfc4291.html#section-2.5.5.2 IPv4-mapped IPv6 Address
+ */
+final class OnlineLog extends Model
+{
+    protected $connection = 'default';
+
+    protected $table = 'online_log';
+
+    /**
+     * Get human-readable IPv4 or IPv6 address
+     *
+     * @return string Example: IPv4 Address: `1.1.1.1`; IPv6 Address: `2606:4700:4700::1111`
+     */
+    public function ip(): string
+    {
+        $ip = $this->attributes['ip'];
+        if (substr($ip, 0, 7) === '::ffff:') {
+            return substr($ip, 6);
+        }
+        return $ip;
+    }
+}

+ 13 - 12
src/Models/User.php

@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace App\Models;
 
+use App\Services\DB;
 use App\Services\Mail;
 use App\Utils\Hash;
 use App\Utils\Telegram;
@@ -346,17 +347,17 @@ final class User extends Model
      */
     public function onlineIpCount(): int
     {
-        // 根据 IP 分组去重
-        $total = Ip::where('datetime', '>=', time() - 90)->where('userid', $this->id)->orderBy('userid', 'desc')->groupBy('ip')->get();
-        $ip_list = [];
-        foreach ($total as $single_record) {
-            $ip = Tools::getRealIp($single_record->ip);
-            if (Node::where('node_ip', $ip)->first() !== null) {
-                continue;
-            }
-            $ip_list[] = $ip;
-        }
-        return count($ip_list);
+        return DB::select(
+            '
+            SELECT
+                COUNT(*) AS count
+            FROM
+                online_log
+            WHERE
+                user_id = ?
+                AND last_time >= UNIX_TIMESTAMP() - 90',
+            [$this->attributes['id']]
+        )[0]->count;
     }
 
     /**
@@ -373,7 +374,7 @@ final class User extends Model
         DetectLog::where('user_id', '=', $uid)->delete();
         EmailVerify::where('email', $email)->delete();
         InviteCode::where('user_id', '=', $uid)->delete();
-        Ip::where('userid', '=', $uid)->delete();
+        OnlineLog::where('user_id', '=', $uid)->delete();
         Link::where('userid', '=', $uid)->delete();
         LoginIp::where('userid', '=', $uid)->delete();
         PasswordReset::where('email', '=', $email)->delete();

+ 7 - 12
src/Utils/Telegram/Callbacks/Callback.php

@@ -7,9 +7,8 @@ namespace App\Utils\Telegram\Callbacks;
 use App\Controllers\LinkController;
 use App\Controllers\SubController;
 use App\Models\InviteCode;
-use App\Models\Ip;
 use App\Models\LoginIp;
-use App\Models\Node;
+use App\Models\OnlineLog;
 use App\Models\Payback;
 use App\Models\Setting;
 use App\Models\UserSubscribeLog;
@@ -430,18 +429,14 @@ final class Callback
                 break;
             case 'usage_log':
                 // 使用记录
-                $total = Ip::where('datetime', '>=', time() - 300)->where('userid', '=', $this->User->id)->get();
-                $text = '<strong>以下是您最近 5 分钟的使用 IP 和地理位置:</strong>' . PHP_EOL;
+                $logs = OnlineLog::where('user_id', '=', $this->User->id)->orderByDesc('last_time')->get('ip');
+                $text = '<strong>以下是您最近 30 天的使用 IP 和地理位置:</strong>' . PHP_EOL;
                 $text .= PHP_EOL;
 
-                foreach ($total as $single) {
-                    $single->ip = Tools::getRealIp($single->ip);
-                    $is_node = Node::where('node_ip', $single->ip)->first();
-                    if ($is_node) {
-                        continue;
-                    }
-                    $location = Tools::getIpLocation($single->ip);
-                    $text .= $single->ip . ' - ' . $location . PHP_EOL;
+                foreach ($logs as $log) {
+                    $ip = $log->ip();
+                    $location = Tools::getIpLocation($ip);
+                    $text .= "{$ip} - {$location}\n";
                 }
 
                 $text .= PHP_EOL . '<strong>注意:地理位置根据 IP 数据库预估,可能与实际位置不符,仅供参考使用</strong>' . PHP_EOL;

+ 0 - 19
src/Utils/Tools.php

@@ -5,7 +5,6 @@ declare(strict_types=1);
 namespace App\Utils;
 
 use App\Models\Link;
-use App\Models\Model;
 use App\Models\Paylist;
 use App\Models\Setting;
 use App\Models\User;
@@ -201,24 +200,6 @@ final class Tools
         return false;
     }
 
-    /**
-     * Filter key in `App\Models\Model` object
-     */
-    public static function keyFilter(Model $object, array $filter_array): Model
-    {
-        foreach ($object->toArray() as $key => $value) {
-            if (! in_array($key, $filter_array)) {
-                unset($object->$key);
-            }
-        }
-        return $object;
-    }
-
-    public static function getRealIp($rawIp): array|string
-    {
-        return str_replace('::ffff:', '', $rawIp);
-    }
-
     public static function isEmail($input): bool
     {
         if (filter_var($input, FILTER_VALIDATE_EMAIL) === false) {