doge 5 часов назад
Родитель
Сommit
aba677ea9c

+ 0 - 121
app/Helpers/IpCountry.php

@@ -1,121 +0,0 @@
-<?php
-
-namespace App\Helpers;
-
-use Illuminate\Support\Facades\Cache;
-use Illuminate\Support\Facades\Log;
-
-/**
- * 国家/地区:按 IP 解析(带缓存),用于登录日志回填展示
- */
-class IpCountry
-{
-    private const CACHE_TTL = 86400;
-    private const CACHE_PREFIX = 'ip_country_v2_';
-
-    private const CODE_NAME_MAP = [
-        'CN' => '中国',
-        'HK' => '香港',
-        'MO' => '澳门',
-        'TW' => '台湾',
-        'US' => '美国',
-        'JP' => '日本',
-        'KR' => '韩国',
-        'SG' => '新加坡',
-        'MY' => '马来西亚',
-        'TH' => '泰国',
-        'VN' => '越南',
-        'ID' => '印尼',
-        'PH' => '菲律宾',
-        'KH' => '柬埔寨',
-        'MM' => '缅甸',
-        'IN' => '印度',
-        'GB' => '英国',
-        'AU' => '澳大利亚',
-        'CA' => '加拿大',
-        'DE' => '德国',
-        'FR' => '法国',
-        'RU' => '俄罗斯',
-        'BR' => '巴西',
-        'AE' => '阿联酋',
-    ];
-
-    public static function resolve(string $ip): string
-    {
-        $ip = trim($ip);
-        if ($ip === '') {
-            return '';
-        }
-        if (in_array($ip, ['127.0.0.1', '::1'], true)) {
-            return '本地';
-        }
-        if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
-            return '';
-        }
-
-        $cacheKey = self::CACHE_PREFIX . md5($ip);
-        $cached = Cache::get($cacheKey);
-        if (is_string($cached)) {
-            return $cached;
-        }
-
-        $country = self::lookup($ip);
-        Cache::put($cacheKey, $country, self::CACHE_TTL);
-        return $country;
-    }
-
-    /**
-     * 批量解析,减少列表页重复请求
-     *
-     * @param array<int, string> $ips
-     * @return array<string, string> ip => country
-     */
-    public static function resolveMany(array $ips): array
-    {
-        $result = [];
-        foreach (array_unique(array_filter(array_map('trim', $ips))) as $ip) {
-            $result[$ip] = self::resolve($ip);
-        }
-        return $result;
-    }
-
-    protected static function lookup(string $ip): string
-    {
-        $url = 'http://ip-api.com/json/' . rawurlencode($ip)
-            . '?fields=status,country,countryCode,regionName,city&lang=zh-CN';
-
-        try {
-            $ctx = stream_context_create([
-                'http' => [
-                    'timeout' => 2,
-                    'method' => 'GET',
-                ],
-            ]);
-            $raw = @file_get_contents($url, false, $ctx);
-            if ($raw === false || $raw === '') {
-                return '';
-            }
-            $data = json_decode($raw, true);
-            if (!is_array($data) || ($data['status'] ?? '') !== 'success') {
-                return '';
-            }
-
-            $code = strtoupper((string)($data['countryCode'] ?? ''));
-            $country = (string)($data['country'] ?? '');
-            if ($country === '' && $code !== '' && isset(self::CODE_NAME_MAP[$code])) {
-                $country = self::CODE_NAME_MAP[$code];
-            }
-
-            $parts = array_filter([
-                $country,
-                (string)($data['regionName'] ?? ''),
-                (string)($data['city'] ?? ''),
-            ], static fn ($v) => $v !== '');
-
-            return implode(' ', array_unique($parts));
-        } catch (\Throwable $e) {
-            Log::warning('ip_country_lookup_failed', ['ip' => $ip, 'error' => $e->getMessage()]);
-            return '';
-        }
-    }
-}

+ 57 - 0
app/Http/Controllers/admin/Online.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\NewUserSummaryService;
+use App\Services\OnlineUserService;
+use Exception;
+use Illuminate\Validation\ValidationException;
+
+class Online extends Controller
+{
+    /**
+     * 在线用户列表
+     */
+    public function users()
+    {
+        try {
+            $params = request()->validate([
+                'page' => ['nullable', 'integer', 'min:1'],
+                'limit' => ['nullable', 'integer', 'min:1', 'max:200'],
+                'member_id' => ['nullable', 'string'],
+                'first_name' => ['nullable', 'string'],
+                'login_ip' => ['nullable', 'string'],
+                'platform' => ['nullable', 'string'],
+            ]);
+            $result = OnlineUserService::list($params);
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+        return $this->success($result);
+    }
+
+    /**
+     * 新用户汇总(按天)
+     */
+    public function newUserSummary()
+    {
+        try {
+            $params = request()->validate([
+                'start_time' => ['nullable', 'date', 'date_format:Y-m-d'],
+                'end_time' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_time'],
+            ]);
+            $endDate = $params['end_time'] ?? date('Y-m-d');
+            $startDate = $params['start_time'] ?? date('Y-m-d', strtotime($endDate . ' -6 days'));
+            $result = NewUserSummaryService::summarize($startDate, $endDate);
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+        return $this->success($result);
+    }
+}

+ 52 - 38
app/Http/Controllers/admin/User.php

@@ -9,6 +9,7 @@ use App\Services\TopUpService;
 use Illuminate\Support\Facades\App;
 use Illuminate\Support\Facades\App;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
 use App\Services\UserService;
 use App\Services\UserService;
+use App\Services\RegisterStatService;
 use Exception;
 use Exception;
 use Illuminate\Validation\ValidationException;
 use Illuminate\Validation\ValidationException;
 use App\Services\AddressService;
 use App\Services\AddressService;
@@ -17,7 +18,6 @@ use Illuminate\Http\JsonResponse;
 use App\Models\User as UserModel;
 use App\Models\User as UserModel;
 use App\Models\UserSession;
 use App\Models\UserSession;
 use App\Models\UserLogin;
 use App\Models\UserLogin;
-use App\Helpers\IpCountry;
 use App\Models\ThirdGameRecycle;
 use App\Models\ThirdGameRecycle;
 use App\Models\Wallet;
 use App\Models\Wallet;
 use App\Services\BalanceLogService;
 use App\Services\BalanceLogService;
@@ -491,11 +491,7 @@ class User extends Controller
     }
     }
 
 
     /**
     /**
-     * 用户登录日志
-     *
-     * 字段: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()
     public function loginLog()
     {
     {
@@ -520,9 +516,11 @@ class User extends Controller
                     'user_login.user_id',
                     'user_login.user_id',
                     'user_login.login_account',
                     'user_login.login_account',
                     'user_login.login_ip',
                     'user_login.login_ip',
+                    'user_login.login_domain',
                     'user_login.country',
                     'user_login.country',
                     'user_login.browser',
                     'user_login.browser',
                     'user_login.os',
                     'user_login.os',
+                    'user_login.platform',
                     'user_login.status',
                     'user_login.status',
                     'user_login.logout_time',
                     'user_login.logout_time',
                     'user_login.online_duration',
                     'user_login.online_duration',
@@ -559,44 +557,60 @@ class User extends Controller
                 ->forPage($page, $limit)
                 ->forPage($page, $limit)
                 ->orderByDesc('user_login.id')
                 ->orderByDesc('user_login.id')
                 ->get();
                 ->get();
-
-            // 历史数据 country 为空时按 login_ip 回填展示,并尽量写回库
-            $emptyIps = $list
-                ->filter(fn ($row) => empty($row->country) && !empty($row->login_ip))
-                ->pluck('login_ip')
-                ->unique()
-                ->values()
-                ->all();
-            if ($emptyIps) {
-                $countryMap = IpCountry::resolveMany($emptyIps);
-                foreach ($list as $row) {
-                    if (!empty($row->country) || empty($row->login_ip)) {
-                        continue;
-                    }
-                    $country = $countryMap[$row->login_ip] ?? '';
-                    if ($country === '') {
-                        continue;
-                    }
-                    $row->country = $country;
-                }
-                foreach ($countryMap as $ip => $country) {
-                    if ($country === '') {
-                        continue;
-                    }
-                    UserLogin::where('login_ip', $ip)
-                        ->where(function ($q) {
-                            $q->whereNull('country')->orWhere('country', '');
-                        })
-                        ->limit(200)
-                        ->update(['country' => $country]);
-                }
-            }
         } catch (Exception $e) {
         } 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]);
         return $this->success(['total' => $count, 'data' => $list]);
     }
     }
 
 
+    /**
+     * 会员注册走势图
+     */
+    public function registerTrend()
+    {
+        try {
+            $params = request()->validate([
+                'type' => ['nullable', 'string', 'in:today,all'],
+                'start_time' => ['nullable', 'date', 'date_format:Y-m-d'],
+                'end_time' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_time'],
+            ]);
+            $data = RegisterStatService::trend(
+                $params['type'] ?? 'today',
+                $params['start_time'] ?? null,
+                $params['end_time'] ?? null
+            );
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+        return $this->success($data);
+    }
+
+    /**
+     * 注册域名统计
+     */
+    public function registerDomain()
+    {
+        try {
+            $params = request()->validate([
+                'start_time' => ['nullable', 'date', 'date_format:Y-m-d'],
+                'end_time' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_time'],
+                'page' => ['nullable', 'integer', 'min:1'],
+                'limit' => ['nullable', 'integer', 'min:1', 'max:200'],
+            ]);
+            $page = (int)request()->input('page', 1);
+            $limit = (int)request()->input('limit', 50);
+            $data = RegisterStatService::domain(
+                $params['start_time'] ?? null,
+                $params['end_time'] ?? null,
+                $page,
+                $limit
+            );
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+        return $this->success($data);
+    }
+
     function setRechargeChannelGroup()
     function setRechargeChannelGroup()
     {
     {
         try {
         try {

+ 1 - 1
app/Models/User.php

@@ -23,7 +23,7 @@ use Carbon\Carbon;
 class User extends BaseModel
 class User extends BaseModel
 {
 {
     protected $table = 'users';
     protected $table = 'users';
-    protected $fillable = ['usdt', 'is_banned', 'last_active_time', 'visitor_id', 'register_ip', 'status', 'admin_note', 'member_id', 'first_name', 
+    protected $fillable = ['usdt', 'is_banned', 'last_active_time', 'visitor_id', 'register_ip', 'register_domain', 'status', 'admin_note', 'member_id', 'first_name', 
     'game_id', 'username', 'secret_key', 'secret_pass', 'language','user_code', 'agent_user_code','level','recharge_channel_group_id'];
     'game_id', 'username', 'secret_key', 'secret_pass', 'language','user_code', 'agent_user_code','level','recharge_channel_group_id'];
     protected $attributes = [
     protected $attributes = [
         'language' => 'zh',
         'language' => 'zh',

+ 2 - 0
app/Models/UserLogin.php

@@ -20,9 +20,11 @@ class UserLogin extends BaseModel
         'user_id',
         'user_id',
         'login_account',
         'login_account',
         'login_ip',
         'login_ip',
+        'login_domain',
         'country',
         'country',
         'browser',
         'browser',
         'os',
         'os',
+        'platform',
         'status',
         'status',
         'logout_time',
         'logout_time',
         'online_duration',
         'online_duration',

+ 180 - 0
app/Services/NewUserSummaryService.php

@@ -0,0 +1,180 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 新用户汇总:区间集合查询,按天输出(无按天 PHP 循环打库)
+ *
+ * 口径:
+ * - H5/APP:register_domain 是否为包名风格(com.xxx.yyy)
+ * - 活跃会员:当日新注册且当日下注 >= 50
+ * - 有效会员:当日新注册且当日存款 >= 50
+ * - 公司盈利:当日新用户当日 投注额 - 派奖
+ */
+class NewUserSummaryService
+{
+    public const ACTIVE_BET_THRESHOLD = 50;
+    public const VALID_DEPOSIT_THRESHOLD = 50;
+
+    /** MySQL:包名风格域名视为 APP */
+    private const IS_APP_SQL = "(register_domain REGEXP '^(com|org|net|io|cn)\\\\.[a-zA-Z0-9_]+(\\\\.[a-zA-Z0-9_]+)+$')";
+
+    /**
+     * @return array{start_time:string,end_time:string,rules:array,data:list<array>}
+     */
+    public static function summarize(string $startDate, string $endDate): array
+    {
+        $rangeStart = $startDate . ' 00:00:00';
+        $rangeEnd = $endDate . ' 23:59:59';
+        $isApp = self::IS_APP_SQL;
+
+        // 当日新注册用户(供后续 join)
+        $newUsers = DB::table('users')
+            ->selectRaw("
+                DATE(created_at) as reg_date,
+                COALESCE(NULLIF(member_id, ''), user_id) as member_key,
+                CASE WHEN {$isApp} THEN 1 ELSE 0 END as is_app
+            ")
+            ->whereBetween('created_at', [$rangeStart, $rangeEnd]);
+
+        // 1) 注册按天
+        $registerByDay = DB::table('users')
+            ->selectRaw("
+                DATE(created_at) as d,
+                SUM(CASE WHEN {$isApp} THEN 1 ELSE 0 END) as app_register,
+                SUM(CASE WHEN {$isApp} THEN 0 ELSE 1 END) as h5_register
+            ")
+            ->whereBetween('created_at', [$rangeStart, $rangeEnd])
+            ->groupByRaw('DATE(created_at)')
+            ->get()
+            ->keyBy(fn ($r) => (string)$r->d);
+
+        // 2) 首存:新用户首次正充值落在注册日
+        $firstDepositSub = DB::table('balance_logs')
+            ->selectRaw('member_id, MIN(created_at) as first_at')
+            ->where('amount', '>', 0)
+            ->where(function ($q) {
+                $q->whereIn('change_type', ['充值', '人工充值', '三方充值'])
+                    ->orWhere(function ($q2) {
+                        $q2->where('change_type', 'like', '%充值%')
+                            ->where('change_type', 'not like', '%返现%')
+                            ->where('change_type', 'not like', '%退%');
+                    });
+            })
+            ->whereIn('member_id', (clone $newUsers)->selectRaw('COALESCE(NULLIF(member_id, \'\'), user_id)'))
+            ->groupBy('member_id');
+
+        $firstDepositByDay = DB::query()
+            ->fromSub($firstDepositSub, 'fd')
+            ->joinSub($newUsers, 'nu', 'nu.member_key', '=', 'fd.member_id')
+            ->whereRaw('DATE(fd.first_at) = nu.reg_date')
+            ->selectRaw("
+                nu.reg_date as d,
+                SUM(CASE WHEN nu.is_app = 1 THEN 1 ELSE 0 END) as app_first_deposit,
+                SUM(CASE WHEN nu.is_app = 0 THEN 1 ELSE 0 END) as h5_first_deposit
+            ")
+            ->groupBy('nu.reg_date')
+            ->get()
+            ->keyBy(fn ($r) => (string)$r->d);
+
+        // 3) 流水:新用户在其注册日的 投注/存款/派奖(每人一行)
+        $memberFlow = DB::table('balance_logs as bl')
+            ->joinSub($newUsers, 'nu', 'nu.member_key', '=', 'bl.member_id')
+            ->whereRaw('DATE(bl.created_at) = nu.reg_date')
+            ->whereBetween('bl.created_at', [$rangeStart, $rangeEnd])
+            ->selectRaw("
+                nu.reg_date as d,
+                bl.member_id,
+                SUM(CASE
+                    WHEN bl.change_type LIKE '%投注%'
+                         AND bl.change_type NOT LIKE '%退款%'
+                         AND bl.change_type NOT LIKE '%中奖%'
+                    THEN ABS(bl.amount) ELSE 0 END) as bet_amt,
+                SUM(CASE
+                    WHEN bl.amount > 0 AND (
+                        bl.change_type IN ('充值','人工充值','三方充值')
+                        OR (
+                            bl.change_type LIKE '%充值%'
+                            AND bl.change_type NOT LIKE '%返现%'
+                            AND bl.change_type NOT LIKE '%退%'
+                        )
+                    ) THEN bl.amount ELSE 0 END) as deposit_amt,
+                SUM(CASE
+                    WHEN bl.change_type LIKE '%中奖%' THEN bl.amount ELSE 0 END) as win_amt
+            ")
+            ->groupBy('nu.reg_date', 'bl.member_id')
+            ->get();
+
+        $flowByDay = [];
+        foreach ($memberFlow as $row) {
+            $d = (string)$row->d;
+            if (!isset($flowByDay[$d])) {
+                $flowByDay[$d] = [
+                    'bet_total' => 0.0,
+                    'deposit_total' => 0.0,
+                    'win_total' => 0.0,
+                    'active_member' => 0,
+                    'valid_member' => 0,
+                ];
+            }
+            $bet = (float)$row->bet_amt;
+            $deposit = (float)$row->deposit_amt;
+            $win = (float)$row->win_amt;
+            $flowByDay[$d]['bet_total'] += $bet;
+            $flowByDay[$d]['deposit_total'] += $deposit;
+            $flowByDay[$d]['win_total'] += $win;
+            if ($bet >= self::ACTIVE_BET_THRESHOLD) {
+                $flowByDay[$d]['active_member']++;
+            }
+            if ($deposit >= self::VALID_DEPOSIT_THRESHOLD) {
+                $flowByDay[$d]['valid_member']++;
+            }
+        }
+
+        $data = [];
+        $cursor = strtotime($startDate);
+        $endTs = strtotime($endDate);
+        while ($cursor <= $endTs) {
+            $d = date('Y-m-d', $cursor);
+            $reg = $registerByDay[$d] ?? null;
+            $fd = $firstDepositByDay[$d] ?? null;
+            $flow = $flowByDay[$d] ?? [
+                'bet_total' => 0.0,
+                'deposit_total' => 0.0,
+                'win_total' => 0.0,
+                'active_member' => 0,
+                'valid_member' => 0,
+            ];
+
+            $betTotal = round((float)$flow['bet_total'], 2);
+            $depositTotal = round((float)$flow['deposit_total'], 2);
+            $winTotal = round((float)$flow['win_total'], 2);
+
+            $data[] = [
+                'date' => $d,
+                'h5_register' => (int)($reg->h5_register ?? 0),
+                'app_register' => (int)($reg->app_register ?? 0),
+                'h5_first_deposit' => (int)($fd->h5_first_deposit ?? 0),
+                'app_first_deposit' => (int)($fd->app_first_deposit ?? 0),
+                'active_member' => (int)$flow['active_member'],
+                'deposit_total' => $depositTotal,
+                'bet_total' => $betTotal,
+                'company_profit' => round($betTotal - $winTotal, 2),
+                'valid_member' => (int)$flow['valid_member'],
+            ];
+            $cursor = strtotime('+1 day', $cursor);
+        }
+
+        return [
+            'start_time' => $startDate,
+            'end_time' => $endDate,
+            'rules' => [
+                'active_member' => '新注册会员当日下注>=¥' . self::ACTIVE_BET_THRESHOLD,
+                'valid_member' => '新注册会员当日存款>=¥' . self::VALID_DEPOSIT_THRESHOLD,
+            ],
+            'data' => $data,
+        ];
+    }
+}

+ 126 - 0
app/Services/OnlineUserService.php

@@ -0,0 +1,126 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\User;
+use App\Models\UserLogin;
+use Carbon\Carbon;
+
+/**
+ * 在线用户列表(只读,不写库、不打外网)
+ */
+class OnlineUserService
+{
+    /** 最近活跃秒数 */
+    public const ONLINE_WINDOW_SECONDS = 1800;
+
+    /**
+     * @param array{page?:int,limit?:int,member_id?:string,first_name?:string,login_ip?:string,platform?:string} $params
+     * @return array{total:int,data:list<array>}
+     */
+    public static function list(array $params): array
+    {
+        $page = max(1, (int)($params['page'] ?? 1));
+        $limit = min(200, max(1, (int)($params['limit'] ?? 15)));
+        $since = time() - self::ONLINE_WINDOW_SECONDS;
+
+        $latestLogin = UserLogin::query()
+            ->selectRaw('user_id, MAX(id) as id')
+            ->where('status', UserLogin::STATUS_SUCCESS)
+            ->groupBy('user_id');
+
+        $query = User::query()
+            ->from('users')
+            ->leftJoin('wallets', 'wallets.member_id', '=', 'users.member_id')
+            ->leftJoinSub($latestLogin, 'latest_login', function ($join) {
+                $join->on('latest_login.user_id', '=', 'users.user_id');
+            })
+            ->leftJoin('user_login as ul', 'ul.id', '=', 'latest_login.id')
+            ->where('users.last_active_time', '>=', $since)
+            ->where(function ($q) {
+                $q->whereNull('users.from')->orWhere('users.from', '<>', '2');
+            });
+
+        if (!empty($params['member_id'])) {
+            $mid = $params['member_id'];
+            $query->where(function ($q) use ($mid) {
+                $q->where('users.member_id', $mid)->orWhere('users.user_id', $mid);
+            });
+        }
+        if (!empty($params['first_name'])) {
+            $query->where('users.first_name', 'like', '%' . $params['first_name'] . '%');
+        }
+        if (!empty($params['login_ip'])) {
+            $query->where('ul.login_ip', 'like', '%' . $params['login_ip'] . '%');
+        }
+        if (!empty($params['platform']) && $params['platform'] !== '全部') {
+            $query->where('ul.platform', $params['platform']);
+        }
+
+        $query->select([
+            'users.id',
+            'users.member_id',
+            'users.user_id',
+            'users.first_name',
+            'users.last_active_time',
+            'wallets.available_balance',
+            'ul.login_ip',
+            'ul.country',
+            'ul.login_domain',
+            'ul.platform',
+            'ul.browser',
+            'ul.os',
+            'ul.created_at as login_time',
+        ]);
+
+        $total = (int)(clone $query)->toBase()->distinct()->count('users.id');
+
+        $list = $query
+            ->orderByDesc('users.last_active_time')
+            ->forPage($page, $limit)
+            ->get()
+            ->map(function ($row) {
+                return [
+                    'member_id' => $row->member_id ?: $row->user_id,
+                    'first_name' => (string)($row->first_name ?? ''),
+                    'available_balance' => (float)($row->available_balance ?? 0),
+                    'login_ip' => (string)($row->login_ip ?? ''),
+                    'location' => (string)($row->country ?? ''),
+                    'login_domain' => (string)($row->login_domain ?? ''),
+                    'platform' => (string)($row->platform ?: self::guessPlatformFromOs((string)($row->os ?? ''))),
+                    'browser' => (string)($row->browser ?? ''),
+                    'os' => (string)($row->os ?? ''),
+                    'login_time' => self::formatTime($row->login_time ?? null),
+                    'last_active_time' => $row->last_active_time,
+                ];
+            })
+            ->values()
+            ->all();
+
+        return ['total' => $total, 'data' => $list];
+    }
+
+    private static function formatTime($value): string
+    {
+        if (empty($value)) {
+            return '';
+        }
+        try {
+            return Carbon::parse($value)->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
+        } catch (\Throwable $e) {
+            return (string)$value;
+        }
+    }
+
+    private static function guessPlatformFromOs(string $os): string
+    {
+        $os = strtolower($os);
+        if (str_contains($os, 'android')) {
+            return 'Android';
+        }
+        if (str_contains($os, 'ios') || str_contains($os, 'iphone') || str_contains($os, 'ipad')) {
+            return 'iOS';
+        }
+        return 'PC';
+    }
+}

+ 120 - 0
app/Services/RegisterStatService.php

@@ -0,0 +1,120 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\User;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 注册走势 / 注册域名统计
+ */
+class RegisterStatService
+{
+    /**
+     * @param string $type today|all
+     * @return array{type:string,times:array,member:array,virtual:array,member_total:int,virtual_total:int,...}
+     */
+    public static function trend(string $type, ?string $startDate, ?string $endDate): array
+    {
+        $today = date('Y-m-d');
+        $startDate = $startDate ?: $today;
+        $endDate = $endDate ?: $today;
+
+        if ($type === 'today') {
+            $day = $startDate;
+            $bucketExpr = "DATE_FORMAT(created_at, '%H:00')";
+            $times = [];
+            for ($h = 0; $h < 24; $h++) {
+                $times[] = sprintf('%02d:00', $h);
+            }
+            $rangeStart = $day . ' 00:00:00';
+            $rangeEnd = $day . ' 23:59:59';
+            $meta = ['type' => 'today', 'date' => $day];
+        } else {
+            $bucketExpr = "DATE_FORMAT(created_at, '%Y-%m-%d')";
+            $times = [];
+            $cursor = strtotime($startDate);
+            $endTs = strtotime($endDate);
+            while ($cursor <= $endTs) {
+                $times[] = date('Y-m-d', $cursor);
+                $cursor = strtotime('+1 day', $cursor);
+            }
+            $rangeStart = $startDate . ' 00:00:00';
+            $rangeEnd = $endDate . ' 23:59:59';
+            $meta = [
+                'type' => 'all',
+                'start_time' => $startDate,
+                'end_time' => $endDate,
+            ];
+        }
+
+        $rows = User::query()
+            ->selectRaw("{$bucketExpr} as time_key, `from` as user_from, COUNT(*) as cnt")
+            ->whereBetween('created_at', [$rangeStart, $rangeEnd])
+            ->groupByRaw("{$bucketExpr}, `from`")
+            ->get();
+
+        $memberMap = array_fill_keys($times, 0);
+        $virtualMap = array_fill_keys($times, 0);
+        foreach ($rows as $row) {
+            $key = (string)$row->time_key;
+            if (!array_key_exists($key, $memberMap)) {
+                continue;
+            }
+            if ((string)$row->user_from === '2') {
+                $virtualMap[$key] += (int)$row->cnt;
+            } else {
+                $memberMap[$key] += (int)$row->cnt;
+            }
+        }
+
+        return array_merge($meta, [
+            'times' => $times,
+            'member' => array_values($memberMap),
+            'virtual' => array_values($virtualMap),
+            'member_total' => array_sum($memberMap),
+            'virtual_total' => array_sum($virtualMap),
+        ]);
+    }
+
+    /**
+     * 域名统计(SQL 分页)
+     *
+     * @return array{total:int,data:list<array{register_url:string,register_count:int}>}
+     */
+    public static function domain(?string $startDate, ?string $endDate, int $page, int $limit): array
+    {
+        $domainExpr = "IF(register_domain = '' OR register_domain IS NULL, '未知', register_domain)";
+
+        $base = User::query();
+        if ($startDate) {
+            $base->where('created_at', '>=', $startDate . ' 00:00:00');
+        }
+        if ($endDate) {
+            $base->where('created_at', '<=', $endDate . ' 23:59:59');
+        }
+
+        $grouped = (clone $base)
+            ->selectRaw("{$domainExpr} as register_url, COUNT(*) as register_count")
+            ->groupByRaw($domainExpr);
+
+        $total = (int)DB::query()->fromSub(
+            (clone $grouped),
+            'domain_groups'
+        )->count();
+
+        $offset = max(0, ($page - 1) * $limit);
+        $rows = (clone $grouped)
+            ->orderByDesc(DB::raw('register_count'))
+            ->offset($offset)
+            ->limit($limit)
+            ->get();
+
+        $data = $rows->map(fn ($row) => [
+            'register_url' => (string)$row->register_url,
+            'register_count' => (int)$row->register_count,
+        ])->values()->all();
+
+        return ['total' => $total, 'data' => $data];
+    }
+}

+ 3 - 0
database/migrations/2026_08_05_120000_add_register_domain.sql

@@ -0,0 +1,3 @@
+-- 注册域名统计字段
+ALTER TABLE `bot_users`
+  ADD COLUMN `register_domain` varchar(255) NOT NULL DEFAULT '' COMMENT '注册来源域名(Referer 主机)' AFTER `register_ip`;

+ 117 - 0
database/migrations/2026_08_05_120000_add_register_domain_and_menu.php

@@ -0,0 +1,117 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    private const MENU_URI = 'registerDomain';
+
+    private const BUTTONS = [
+        'admin/user/registerTrend' => '注册走势',
+        'admin/user/registerDomain' => '域名统计',
+    ];
+
+    public function up()
+    {
+        if (Schema::hasTable('users') && !Schema::hasColumn('users', 'register_domain')) {
+            Schema::table('users', function (Blueprint $table) {
+                $table->string('register_domain', 255)
+                    ->default('')
+                    ->comment('注册来源域名(Referer 主机)')
+                    ->after('register_ip');
+            });
+        }
+
+        if (!Schema::hasTable('menus')) {
+            return;
+        }
+
+        $now = date('Y-m-d H:i:s');
+        $parent = DB::table('menus')
+            ->where('uri', 'user')
+            ->where('type', 1)
+            ->where('parent_id', 0)
+            ->first(['id']);
+
+        // 兼容「会员管理」父菜单
+        if (!$parent) {
+            $parent = DB::table('menus')
+                ->where('title', '会员管理')
+                ->where('type', 1)
+                ->first(['id']);
+        }
+
+        $parentId = $parent ? (int)$parent->id : 0;
+
+        $menu = DB::table('menus')
+            ->where('uri', self::MENU_URI)
+            ->where('type', 1)
+            ->first(['id', 'status']);
+
+        if (!$menu) {
+            $menuId = DB::table('menus')->insertGetId([
+                'parent_id' => $parentId,
+                'title' => '注册/域名统计',
+                'icon' => null,
+                'uri' => self::MENU_URI,
+                'permission_name' => 'user.register_domain',
+                'sort' => 4,
+                'status' => 1,
+                'type' => 1,
+                'created_at' => $now,
+                'updated_at' => $now,
+            ]);
+        } else {
+            $menuId = (int)$menu->id;
+            DB::table('menus')->where('id', $menuId)->update([
+                'parent_id' => $parentId,
+                'title' => '注册/域名统计',
+                'status' => 1,
+                'updated_at' => $now,
+            ]);
+        }
+
+        $sort = 0;
+        foreach (self::BUTTONS as $uri => $title) {
+            $button = DB::table('menus')->where('uri', $uri)->first(['id']);
+            if ($button) {
+                DB::table('menus')->where('id', $button->id)->update([
+                    'parent_id' => $menuId,
+                    'title' => $title,
+                    'status' => 1,
+                    'type' => 2,
+                    'updated_at' => $now,
+                ]);
+            } else {
+                DB::table('menus')->insert([
+                    'parent_id' => $menuId,
+                    'title' => $title,
+                    'icon' => null,
+                    'uri' => $uri,
+                    'permission_name' => str_replace('/', '.', $uri),
+                    'sort' => $sort,
+                    'status' => 1,
+                    'type' => 2,
+                    'created_at' => $now,
+                    'updated_at' => $now,
+                ]);
+            }
+            $sort++;
+        }
+    }
+
+    public function down()
+    {
+        if (Schema::hasTable('users') && Schema::hasColumn('users', 'register_domain')) {
+            Schema::table('users', function (Blueprint $table) {
+                $table->dropColumn('register_domain');
+            });
+        }
+
+        if (Schema::hasTable('menus')) {
+            DB::table('menus')->whereIn('uri', array_merge([self::MENU_URI], array_keys(self::BUTTONS)))->delete();
+        }
+    }
+};

+ 3 - 0
database/migrations/2026_08_05_140000_add_online_summary.sql

@@ -0,0 +1,3 @@
+ALTER TABLE `bot_user_login`
+  ADD COLUMN `login_domain` varchar(255) NOT NULL DEFAULT '' COMMENT '登录网站(Referer 主机)' AFTER `login_ip`,
+  ADD COLUMN `platform` varchar(32) NOT NULL DEFAULT '' COMMENT '平台:PC/Android/iOS/WeChat' AFTER `os`;

+ 112 - 0
database/migrations/2026_08_05_140000_add_online_summary_fields_and_menu.php

@@ -0,0 +1,112 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    private const MENU_URI = 'onlineSummary';
+
+    private const BUTTONS = [
+        'admin/online/users' => '在线用户',
+        'admin/online/newUserSummary' => '新用户汇总',
+    ];
+
+    public function up()
+    {
+        if (Schema::hasTable('user_login')) {
+            Schema::table('user_login', function (Blueprint $table) {
+                if (!Schema::hasColumn('user_login', 'login_domain')) {
+                    $table->string('login_domain', 255)->default('')->comment('登录网站(Referer 主机)')->after('login_ip');
+                }
+                if (!Schema::hasColumn('user_login', 'platform')) {
+                    $table->string('platform', 32)->default('')->comment('平台:PC/Android/iOS/WeChat')->after('os');
+                }
+            });
+        }
+
+        if (!Schema::hasTable('menus')) {
+            return;
+        }
+
+        $now = date('Y-m-d H:i:s');
+        $parent = DB::table('menus')
+            ->where('uri', 'user')
+            ->where('type', 1)
+            ->where('parent_id', 0)
+            ->first(['id']);
+        if (!$parent) {
+            $parent = DB::table('menus')->where('title', '会员管理')->where('type', 1)->first(['id']);
+        }
+        $parentId = $parent ? (int)$parent->id : 0;
+
+        $menu = DB::table('menus')->where('uri', self::MENU_URI)->where('type', 1)->first(['id']);
+        if (!$menu) {
+            $menuId = DB::table('menus')->insertGetId([
+                'parent_id' => $parentId,
+                'title' => '在线汇总',
+                'icon' => null,
+                'uri' => self::MENU_URI,
+                'permission_name' => 'user.online_summary',
+                'sort' => 5,
+                'status' => 1,
+                'type' => 1,
+                'created_at' => $now,
+                'updated_at' => $now,
+            ]);
+        } else {
+            $menuId = (int)$menu->id;
+            DB::table('menus')->where('id', $menuId)->update([
+                'parent_id' => $parentId,
+                'title' => '在线汇总',
+                'status' => 1,
+                'updated_at' => $now,
+            ]);
+        }
+
+        $sort = 0;
+        foreach (self::BUTTONS as $uri => $title) {
+            $button = DB::table('menus')->where('uri', $uri)->first(['id']);
+            if ($button) {
+                DB::table('menus')->where('id', $button->id)->update([
+                    'parent_id' => $menuId,
+                    'title' => $title,
+                    'status' => 1,
+                    'type' => 2,
+                    'updated_at' => $now,
+                ]);
+            } else {
+                DB::table('menus')->insert([
+                    'parent_id' => $menuId,
+                    'title' => $title,
+                    'icon' => null,
+                    'uri' => $uri,
+                    'permission_name' => str_replace('/', '.', $uri),
+                    'sort' => $sort,
+                    'status' => 1,
+                    'type' => 2,
+                    'created_at' => $now,
+                    'updated_at' => $now,
+                ]);
+            }
+            $sort++;
+        }
+    }
+
+    public function down()
+    {
+        if (Schema::hasTable('user_login')) {
+            Schema::table('user_login', function (Blueprint $table) {
+                foreach (['login_domain', 'platform'] as $col) {
+                    if (Schema::hasColumn('user_login', $col)) {
+                        $table->dropColumn($col);
+                    }
+                }
+            });
+        }
+        if (Schema::hasTable('menus')) {
+            DB::table('menus')->whereIn('uri', array_merge([self::MENU_URI], array_keys(self::BUTTONS)))->delete();
+        }
+    }
+};

+ 7 - 1
routes/admin.php

@@ -44,6 +44,7 @@ use App\Http\Controllers\admin\JisuGame;
 use App\Http\Controllers\admin\JisuGameOrder;
 use App\Http\Controllers\admin\JisuGameOrder;
 use App\Http\Controllers\admin\JisuLottery;
 use App\Http\Controllers\admin\JisuLottery;
 use App\Http\Controllers\admin\Egame;
 use App\Http\Controllers\admin\Egame;
+use App\Http\Controllers\admin\Online;
 
 
 Route::post('/login', [Admin::class, 'login']);
 Route::post('/login', [Admin::class, 'login']);
 Route::get('/test', [Wallet::class, 'test']);
 Route::get('/test', [Wallet::class, 'test']);
@@ -232,14 +233,19 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::post('/setNote', [User::class, 'setNote']);
             Route::post('/setNote', [User::class, 'setNote']);
             Route::post('/banned', [User::class, 'banned']);
             Route::post('/banned', [User::class, 'banned']);
             Route::get('/loginLog', [User::class, 'loginLog']);
             Route::get('/loginLog', [User::class, 'loginLog']);
+            Route::get('/registerTrend', [User::class, 'registerTrend']);
+            Route::get('/registerDomain', [User::class, 'registerDomain']);
             Route::post('/setRechargeChannelGroup', [User::class, 'setRechargeChannelGroup']);
             Route::post('/setRechargeChannelGroup', [User::class, 'setRechargeChannelGroup']);
             Route::post('/setPassword', [User::class, 'setPassword']);
             Route::post('/setPassword', [User::class, 'setPassword']);
             Route::get('/thirdGame/detail', [User::class, 'thirdGameDetail']);
             Route::get('/thirdGame/detail', [User::class, 'thirdGameDetail']);
             Route::post('/thirdGame/recycle', [User::class, 'recycleThirdGameBalance']);
             Route::post('/thirdGame/recycle', [User::class, 'recycleThirdGameBalance']);
             Route::get('/thirdGame/recycleRecords', [User::class, 'thirdGameRecycleRecords']);
             Route::get('/thirdGame/recycleRecords', [User::class, 'thirdGameRecycleRecords']);
             Route::post('/thirdGame/recycleResolve', [User::class, 'resolveThirdGameRecycle']);
             Route::post('/thirdGame/recycleResolve', [User::class, 'resolveThirdGameRecycle']);
+        });
 
 
-
+        Route::prefix('/online')->group(function () {
+            Route::get('/users', [Online::class, 'users']);
+            Route::get('/newUserSummary', [Online::class, 'newUserSummary']);
         });
         });
 
 
         Route::prefix('/userFeedback')->group(function () {
         Route::prefix('/userFeedback')->group(function () {