doge 2 дней назад
Родитель
Сommit
83ceeb1b01

+ 9 - 0
app/Http/Controllers/admin/LhcOrder.php

@@ -37,6 +37,8 @@ class LhcOrder extends Controller
                 'is_faker' => ['nullable', 'integer'],
                 'id' => ['nullable', 'integer'],
                 'type' => ['nullable', 'integer'],
+                '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', 'after_or_equal:start_time'],
             ]);
             $page = request()->input('page', 1);
             $limit = request()->input('limit', 15);
@@ -77,6 +79,13 @@ class LhcOrder extends Controller
             if (!empty($params['first_name'])) {
                 $query = $query->where('users.first_name', 'like', "%{$params['first_name']}%");
             }
+            // created_at 为 Unix 时间戳(模型 dateFormat=U)
+            if (!empty($params['start_time'])) {
+                $query = $query->where('lhc_order.created_at', '>=', strtotime($params['start_time'] . ' 00:00:00'));
+            }
+            if (!empty($params['end_time'])) {
+                $query = $query->where('lhc_order.created_at', '<=', strtotime($params['end_time'] . ' 23:59:59'));
+            }
             $count = $query->count();
             $list = $query->select('lhc_order.*','users.first_name','users.member_id')
                 ->forPage($page, $limit)

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

@@ -54,4 +54,18 @@ class Online extends Controller
         }
         return $this->success($result);
     }
+
+    /**
+     * 在线用户按端计数:苹果 APP / 安卓 APP / PC / 手机 H5
+     * 唯一在线人数接口(勿挂到 paymentOrder/unProcessed)
+     */
+    public function platformCount()
+    {
+        try {
+            $result = OnlineUserService::platformCounts();
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+        return $this->success($result);
+    }
 }

+ 1 - 1
app/Http/Controllers/admin/Order.php

@@ -90,7 +90,7 @@ class Order extends Controller
                 'return_status' => ['nullable', 'integer', 'in:0,1,2,3'],
                 'status' => ['nullable', 'integer', 'in:0,1,2,-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'],
+                'end_time' => ['nullable', 'date', 'date_format:Y-m-d', 'required_with:start_time', 'after_or_equal:start_time'],
                 'league' => ['nullable'],
                 'home_team' => ['nullable'],
                 'guest_team' => ['nullable'],

+ 1 - 1
app/Http/Controllers/admin/PaymentOrder.php

@@ -18,7 +18,7 @@ use App\Services\BalanceLogService;
 class PaymentOrder extends Controller
 {
 
-    //统计后台待处理订单总数
+    //统计后台待处理订单总数(在线平台人数见 GET /admin/online/platformCount)
     public function unProcessed()
     {
         //USDT充值订单

+ 3 - 2
app/Services/NewUserSummaryService.php

@@ -2,13 +2,14 @@
 
 namespace App\Services;
 
+use App\Support\AppDomain;
 use Illuminate\Support\Facades\DB;
 
 /**
  * 新用户汇总:区间集合查询,按天输出
  *
  * 口径:
- * - H5/APP:register_domain 是否为包名风格(com.xxx.yyy)
+ * - H5/APP:register_domain 是否为包名风格(com.xxx.yyy),见 AppDomain
  * - 活跃会员:当日新注册且当日下注 >= 50
  * - 有效会员:当日新注册且当日存款 >= 50
  * - 公司盈利:当日新用户当日 投注额 - 派奖
@@ -32,7 +33,7 @@ class NewUserSummaryService
         $logs = $p . 'balance_logs';
 
         // 包名风格 = APP
-        $isApp = "(u.register_domain REGEXP '^(com|org|net|io|cn)\\\\.[a-zA-Z0-9_]+(\\\\.[a-zA-Z0-9_]+)+$')";
+        $isApp = AppDomain::sqlIsApp('u.register_domain');
 
         // 1) 注册按天
         $registerRows = DB::select(

+ 91 - 43
app/Services/OnlineUserService.php

@@ -3,17 +3,20 @@
 namespace App\Services;
 
 use App\Models\UserLogin;
+use App\Support\LoginPlatform;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
 
 /**
- * 在线用户列表(只读)
+ * 在线用户(只读)
  *
  * 在线判定(满足其一即可):
  * 1. 存在未过期 user_session(expire_time 为 unix 时间戳)
  * 2. last_active_time 在最近 ONLINE_WINDOW_SECONDS 内(unix 时间戳)
  *
+ * platform 口径由登录写入侧一次写定;本服务只读、映射,不再二次推断 APP/H5。
+ *
  * 注意:本库表前缀 + 多表 collation 不一致,涉及跨表字符串比较一律用带前缀表名 + COLLATE。
  */
 class OnlineUserService
@@ -28,11 +31,9 @@ class OnlineUserService
     {
         $page = max(1, (int)($params['page'] ?? 1));
         $limit = min(200, max(1, (int)($params['limit'] ?? 15)));
-        $now = time();
-        $activeSince = $now - self::ONLINE_WINDOW_SECONDS;
         $offset = ($page - 1) * $limit;
 
-        $p = DB::getTablePrefix(); // bot_
+        $p = DB::getTablePrefix();
         $users = $p . 'users';
         $session = $p . 'user_session';
         $wallets = $p . 'wallets';
@@ -45,26 +46,8 @@ class OnlineUserService
         $hasOs = Schema::hasColumn('user_login', 'os');
         $hasCountry = Schema::hasColumn('user_login', 'country');
 
-        $where = [];
-        $bindings = [];
-
-        // 在线条件
-        $where[] = "(
-            EXISTS (
-                SELECT 1 FROM {$session} us
-                WHERE (
-                    us.user_id COLLATE utf8mb4_general_ci = u.user_id COLLATE utf8mb4_general_ci
-                    OR us.user_id COLLATE utf8mb4_general_ci = u.member_id COLLATE utf8mb4_general_ci
-                )
-                AND us.expire_time > ?
-            )
-            OR (u.last_active_time > 1000000000 AND u.last_active_time >= ?)
-        )";
-        $bindings[] = $now;
-        $bindings[] = $activeSince;
-
-        // 排除虚拟游客
-        $where[] = "(u.`from` IS NULL OR u.`from` <> 2)";
+        [$onlineSql, $bindings] = self::onlinePredicateSql($session, 'u');
+        $where = [$onlineSql, '(u.`from` IS NULL OR u.`from` <> 2)'];
 
         if (!empty($params['member_id'])) {
             $where[] = '(u.member_id = ? OR u.user_id = ?)';
@@ -76,8 +59,6 @@ class OnlineUserService
             $bindings[] = '%' . $params['first_name'] . '%';
         }
 
-        // 登录 IP / 平台:按最近一次成功登录筛
-        $loginJoinExtra = '';
         $statusCond = $hasLoginStatus ? 'AND status = ' . (int)UserLogin::STATUS_SUCCESS : '';
         if (!empty($params['login_ip']) || (!empty($params['platform']) && $params['platform'] !== '全部' && $hasPlatform)) {
             $loginFilters = [];
@@ -170,11 +151,8 @@ class OnlineUserService
                 'login_ip' => (string)($row['login_ip'] ?? ''),
                 'location' => (string)($row['country'] ?? ''),
                 'login_domain' => (string)($row['login_domain'] ?? ''),
-                'platform' => (string)(
-                    ($row['platform'] ?? '') !== ''
-                        ? $row['platform']
-                        : self::guessPlatformFromOs((string)($row['os'] ?? ''))
-                ),
+                // 不二次猜测:无 platform 则空,避免与计数口径漂移
+                'platform' => (string)($row['platform'] ?? ''),
                 'browser' => (string)($row['browser'] ?? ''),
                 'os' => (string)($row['os'] ?? ''),
                 'login_time' => self::formatTime($row['login_time'] ?? null),
@@ -185,6 +163,88 @@ class OnlineUserService
         return ['total' => $total, 'data' => $data];
     }
 
+    /**
+     * 当前在线用户按端计数(与 list 相同在线判定)
+     *
+     * 信任 user_login.platform 写入值,GROUP BY 后映射:
+     * - apple   ← iOS
+     * - android ← Android
+     * - pc      ← PC
+     * - mobile  ← H5 + WeChat(手机 H5)
+     *
+     * @return array{total:int,apple:int,android:int,pc:int,mobile:int,wechat:int,unknown:int}
+     */
+    public static function platformCounts(): array
+    {
+        $p = DB::getTablePrefix();
+        $users = $p . 'users';
+        $session = $p . 'user_session';
+        $login = $p . 'user_login';
+
+        $statusCond = Schema::hasColumn('user_login', 'status')
+            ? 'AND status = ' . (int)UserLogin::STATUS_SUCCESS
+            : '';
+
+        [$onlineSql, $bindings] = self::onlinePredicateSql($session, 'u');
+        $whereSql = $onlineSql . ' AND (u.`from` IS NULL OR u.`from` <> 2)';
+
+        $rows = DB::select(
+            "SELECT
+                COALESCE(NULLIF(ul.platform, ''), '') AS platform,
+                COUNT(*) AS c
+             FROM {$users} u
+             LEFT JOIN (
+                SELECT user_id, MAX(id) AS max_id
+                FROM {$login}
+                WHERE 1=1 {$statusCond}
+                GROUP BY user_id
+             ) latest ON latest.user_id COLLATE utf8mb4_general_ci = u.user_id COLLATE utf8mb4_general_ci
+             LEFT JOIN {$login} ul ON ul.id = latest.max_id
+             WHERE {$whereSql}
+             GROUP BY COALESCE(NULLIF(ul.platform, ''), '')",
+            $bindings
+        );
+
+        $counts = LoginPlatform::emptyCounts();
+        foreach ($rows as $row) {
+            $platform = (string)($row->platform ?? '');
+            $n = (int)($row->c ?? 0);
+            $counts['total'] += $n;
+
+            $bucket = LoginPlatform::toBucket($platform);
+            $counts[$bucket] += $n;
+
+            if (in_array(strtolower($platform), ['wechat', '微信'], true)) {
+                $counts['wechat'] += $n;
+            }
+        }
+
+        return $counts;
+    }
+
+    /**
+     * 在线判定 SQL 片段 + bindings(now, activeSince)
+     *
+     * @return array{0:string,1:list<int>}
+     */
+    private static function onlinePredicateSql(string $sessionTable, string $userAlias = 'u'): array
+    {
+        $now = time();
+        $activeSince = $now - self::ONLINE_WINDOW_SECONDS;
+        $sql = "(
+            EXISTS (
+                SELECT 1 FROM {$sessionTable} us
+                WHERE (
+                    us.user_id COLLATE utf8mb4_general_ci = {$userAlias}.user_id COLLATE utf8mb4_general_ci
+                    OR us.user_id COLLATE utf8mb4_general_ci = {$userAlias}.member_id COLLATE utf8mb4_general_ci
+                )
+                AND us.expire_time > ?
+            )
+            OR ({$userAlias}.last_active_time > 1000000000 AND {$userAlias}.last_active_time >= ?)
+        )";
+        return [$sql, [$now, $activeSince]];
+    }
+
     private static function formatTime($value): string
     {
         if ($value === null || $value === '') {
@@ -207,16 +267,4 @@ class OnlineUserService
         }
         return self::formatTime($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';
-    }
 }

+ 36 - 0
app/Support/AppDomain.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Support;
+
+/**
+ * 包名风格域名 = 原生 APP 客户端(与注册域名 / 登录域名共用口径)
+ *
+ * 例:com.example.app
+ */
+final class AppDomain
+{
+    /** MySQL REGEXP 主体(不含定界符),用于 raw SQL */
+    public const SQL_REGEXP = "^(com|org|net|io|cn)\\\\.[a-zA-Z0-9_]+(\\\\.[a-zA-Z0-9_]+)+$";
+
+    /** PHP preg 模式 */
+    public const PHP_PATTERN = '/^(com|org|net|io|cn)\.[a-z0-9_]+(\.[a-z0-9_]+)+$/i';
+
+    public static function matches(string $host): bool
+    {
+        $host = strtolower(trim($host));
+        if ($host === '') {
+            return false;
+        }
+        return (bool)preg_match(self::PHP_PATTERN, $host);
+    }
+
+    /**
+     * SQL 布尔表达式:列是否为包名域名
+     *
+     * @param string $column 已带表别名的列,如 u.register_domain
+     */
+    public static function sqlIsApp(string $column): string
+    {
+        return "({$column} REGEXP '" . self::SQL_REGEXP . "')";
+    }
+}

+ 56 - 0
app/Support/LoginPlatform.php

@@ -0,0 +1,56 @@
+<?php
+
+namespace App\Support;
+
+/**
+ * user_login.platform 规范值 → 后台统计 bucket
+ *
+ * 写入侧(melbet)负责一次写对:
+ * - iOS / Android = 原生 APP
+ * - H5 = 手机 H5
+ * - PC = 电脑
+ * - WeChat = 微信(统计并入 mobile)
+ *
+ * 读侧只做映射,不再用 login_domain 二次推断。
+ */
+final class LoginPlatform
+{
+    public const BUCKET_APPLE = 'apple';
+    public const BUCKET_ANDROID = 'android';
+    public const BUCKET_PC = 'pc';
+    public const BUCKET_MOBILE = 'mobile';
+    public const BUCKET_UNKNOWN = 'unknown';
+
+    /**
+     * @return self::BUCKET_*
+     */
+    public static function toBucket(string $platform): string
+    {
+        $p = strtolower(trim($platform));
+        return match ($p) {
+            'ios', 'apple', '苹果' => self::BUCKET_APPLE,
+            'android', '安卓' => self::BUCKET_ANDROID,
+            'pc', 'windows', 'mac', 'macos', 'linux', 'chrome os', 'chromeos' => self::BUCKET_PC,
+            'h5', 'mobile', '移动', 'wechat', '微信' => self::BUCKET_MOBILE,
+            default => self::BUCKET_UNKNOWN,
+        };
+    }
+
+    /**
+     * 空计数骨架
+     *
+     * @return array{total:int,apple:int,android:int,pc:int,mobile:int,wechat:int,unknown:int}
+     */
+    public static function emptyCounts(): array
+    {
+        return [
+            'total' => 0,
+            'apple' => 0,
+            'android' => 0,
+            'pc' => 0,
+            'mobile' => 0,
+            'wechat' => 0,
+            'unknown' => 0,
+        ];
+    }
+}

+ 66 - 5
docs/admin-online-register-api.md

@@ -19,12 +19,13 @@
 
 ## 一、在线汇总(菜单 uri:`onlineSummary`)
 
-前端两个 Tab 对应两个接口(**后端无 tab 参数**,由前端 Tab 切换调用不同 URL)。
+前端两个 Tab 对应两个接口(**后端无 tab 参数**,由前端 Tab 切换调用不同 URL)。平台人数用专用接口轮询,**不要**挂在 `paymentOrder/unProcessed` 上。
 
 | 前端 Tab | 建议 tab 值(前端自用) | 接口 |
 |----------|-------------------------|------|
 | 在线用户 | `online` | `GET /admin/online/users` |
 | 新用户汇总 | `newUser` | `GET /admin/online/newUserSummary` |
+| (计数 / 角标) | - | `GET /admin/online/platformCount` |
 
 ---
 
@@ -55,7 +56,7 @@ GET /admin/online/users
 | `member_id` | string | 否 | - | 会员 ID(匹配 `member_id` 或 `user_id`) |
 | `first_name` | string | 否 | - | 会员昵称,模糊匹配 |
 | `login_ip` | string | 否 | - | IP,模糊匹配最近登录 IP |
-| `platform` | string | 否 | - | 平台:`PC` / `Android` / `iOS` / `WeChat`;空或「全部」表示不限 |
+| `platform` | string | 否 | - | 平台:`PC` / `Android` / `iOS` / `H5` / `WeChat`;空或「全部」表示不限 |
 
 **请求示例**
 
@@ -80,7 +81,7 @@ GET /admin/online/users?page=1&limit=15&member_id=&first_name=&login_ip=&platfor
 | `login_ip` | string | IP地址 | 最近一次成功登录 IP |
 | `location` | string | 位置 | 登录时写入的国家/地区(`country`) |
 | `login_domain` | string | 登录网站 | 登录时 Referer/Origin 主机 |
-| `platform` | string | 平台 | `PC` / `Android` / `iOS` / `WeChat` |
+| `platform` | string | 平台 | `PC`(电脑)/ `iOS`(苹果APP)/ `Android`(安卓APP)/ `H5`(手机H5)/ `WeChat`(微信) |
 | `browser` | string | 浏览器 | |
 | `os` | string | 操作系统 | |
 | `login_time` | string | 登入时间 | `Y-m-d H:i:s`,最近一次成功登录时间 |
@@ -116,6 +117,64 @@ GET /admin/online/users?page=1&limit=15&member_id=&first_name=&login_ip=&platfor
 
 ---
 
+### 1.1.1 在线平台人数(唯一在线人数接口)
+
+**URL**
+
+```http
+GET /admin/online/platformCount
+```
+
+**说明**
+
+当前在线会员按**端**汇总。在线判定与「在线用户」列表相同。
+
+**设计**:登录写入侧(melbet `ClientPlatformDetector`)一次性写定 `user_login.platform`;后台只做 `GROUP BY platform` + 映射,**不再**用 `login_domain` / OS 二次推断。
+
+写入规则摘要:
+
+| platform 入库值 | 含义 | 判定 |
+|-----------------|------|------|
+| `iOS` | 苹果 APP | 包名域名 + iOS(`Sec-CH-UA-Platform` / UA) |
+| `Android` | 安卓 APP | 包名域名 + Android |
+| `PC` | 电脑 | 桌面 OS |
+| `H5` | **手机 H5** | 非 APP 的手机浏览器 |
+| `WeChat` | 微信 | UA 含 MicroMessenger(统计并入 mobile) |
+| `''` | 未知 | 无法判断,不强行归类 |
+
+| 字段 | 含义 | 映射 |
+|------|------|------|
+| `apple` | 苹果 APP | `iOS` |
+| `android` | 安卓 APP | `Android` |
+| `pc` | PC | `PC` |
+| `mobile` | **手机 H5** | `H5` + `WeChat` |
+| `wechat` | 微信(明细,已计入 mobile) | `WeChat` |
+| `unknown` | 未知 | 空 / 未识别 |
+| `total` | 总在线 | 以上合计 |
+
+> `mobile` 专指 **手机浏览器 H5**,与原生 APP 互斥,**不是** 苹果+安卓合计。  
+> `GET /admin/paymentOrder/unProcessed` **只**返回待处理订单角标,不含在线人数。
+
+**返回示例**
+
+```json
+{
+  "code": 0,
+  "msg": "OK",
+  "data": {
+    "total": 128,
+    "apple": 35,
+    "android": 62,
+    "pc": 28,
+    "mobile": 100,
+    "wechat": 3,
+    "unknown": 0
+  }
+}
+```
+
+---
+
 ### 1.2 新用户汇总
 
 **URL**
@@ -379,6 +438,7 @@ GET /admin/user/registerDomain?start_time=2026-07-06&end_time=2026-08-04&page=1&
 |-------------|------|--------|------|
 | `online` | 在线用户 | GET | `/admin/online/users` |
 | `newUser` | 新用户汇总 | GET | `/admin/online/newUserSummary` |
+| - | 平台在线人数(角标可轮询) | GET | `/admin/online/platformCount` |
 
 ### 注册/域名统计页 `registerDomain`
 
@@ -395,7 +455,8 @@ GET /admin/user/registerDomain?start_time=2026-07-06&end_time=2026-08-04&page=1&
 
 | 数据 | 来源 | 说明 |
 |------|------|------|
-| 登录 IP / 浏览器 / 系统 / 平台 / 登录网站 / 位置 | 登录时写入 `user_login` | 需 melbet 登录日志逻辑已发布;历史数据可能为空 |
+| 登录 IP / 浏览器 / 系统 / 平台 / 登录网站 / 位置 | 登录时写入 `user_login` | 需 melbet `ClientPlatformDetector` 已发布;历史 platform 可能为空或旧口径 |
+| 平台(platform) | 写入一次:`Sec-CH-UA-Platform` + 包名域;读侧只映射 | `PC` / `iOS` / `Android` / `H5` / `WeChat` / 空 |
 | 注册域名 | 注册时写入 `users.register_domain` | 来自 Referer/Origin;历史用户多为「未知」 |
 | 在线状态 | `user_session` + `last_active_time` | 以有效 session 为主,30 分钟活跃为辅 |
 | 首存 / 下注 / 存款 | `balance_logs` | 依赖 `change_type` 文案匹配(充值/投注/中奖等) |
@@ -406,5 +467,5 @@ GET /admin/user/registerDomain?start_time=2026-07-06&end_time=2026-08-04&page=1&
 
 | 菜单 | 前端 uri | 按钮/接口权限 uri |
 |------|----------|-------------------|
-| 在线汇总 | `onlineSummary` | `admin/online/users`、`admin/online/newUserSummary` |
+| 在线汇总 | `onlineSummary` | `admin/online/users`、`admin/online/newUserSummary`、`admin/online/platformCount`(无菜单按钮时默认放行) |
 | 注册/域名统计 | `registerDomain` | `admin/user/registerTrend`、`admin/user/registerDomain` |

+ 1 - 0
routes/admin.php

@@ -246,6 +246,7 @@ Route::middleware(['admin.jwt'])->group(function () {
         Route::prefix('/online')->group(function () {
             Route::get('/users', [Online::class, 'users']);
             Route::get('/newUserSummary', [Online::class, 'newUserSummary']);
+            Route::get('/platformCount', [Online::class, 'platformCount']);
         });
 
         Route::prefix('/userFeedback')->group(function () {