doge 6 часов назад
Родитель
Сommit
845b0bc69f

+ 2 - 0
.gitignore

@@ -9,6 +9,8 @@
 /public/layuiadmin/
 #/resources/views/
 /vendor
+/output/playwright/
+/.playwright-cli/
 .env
 .env.backup
 .env.production

+ 2 - 0
app/Services/AdminRealtime/AdminRealtimeFeed.php

@@ -34,6 +34,7 @@ final class AdminRealtimeFeed
     public static function next(int $cursor): array
     {
         self::scanOnline();
+        AdminRealtimeSummaries::scan();
         $limit = max(1, min(1000, (int) config('admin_realtime.batch_size', 200)));
         $gapFrom = now('Asia/Shanghai')->subSeconds(max(0, (int) config('admin_realtime.gap_seconds', 60)))->format('Y-m-d H:i:s');
         [$cursor, $rows] = DB::transaction(function () use ($cursor, $limit, $gapFrom): array {
@@ -172,6 +173,7 @@ final class AdminRealtimeFeed
 
     private static function eventName(string $resource, string $action): string
     {
+        if (isset(AdminRealtimeSummaries::DEFINITIONS[$resource])) return 'stats.updated';
         if ($resource === AdminRealtime::USER_ONLINE) return 'user.' . $action;
         return ($resource === 'users' ? 'user.' : 'order.') . $action;
     }

+ 6 - 1
app/Services/AdminRealtime/AdminRealtimeRows.php

@@ -30,7 +30,7 @@ use RuntimeException;
  */
 final class AdminRealtimeRows
 {
-    /** 频道 => [列表接口, 菜单按钮权限 uri, 行主键字段] */
+    /** 频道 => [HTTP 接口, 菜单按钮权限 uri, 记录标识字段] */
     public const CHANNELS = [
         'admin.orders.sport' => ['/admin/order/list', 'admin/order/list', 'id'],
         'admin.orders.lhc' => ['/admin/lhcOrder/list', 'admin/lhcOrder/list', 'id'],
@@ -46,6 +46,8 @@ final class AdminRealtimeRows
         'admin.orders.manual_audit' => ['/admin/manualAudit', 'admin/manualAudit', 'id'],
         'admin.users' => ['/admin/user', 'admin/user', 'id'],
         'admin.users.online' => ['/admin/online/users', 'admin/online/users', 'member_id'],
+        'admin.stats.platform_count' => ['/admin/online/platformCount', 'admin/online/platformCount', 'scope'],
+        'admin.stats.unprocessed' => ['/admin/paymentOrder/unProcessed', 'admin/paymentOrder/unProcessed', 'scope'],
     ];
 
     /** 表 => 默认频道(payment_orders 按 type 分流,见 paymentOrders) */
@@ -68,6 +70,9 @@ final class AdminRealtimeRows
      */
     public static function resolve(string $resource, string $id, string $action): array
     {
+        if (isset(AdminRealtimeSummaries::DEFINITIONS[$resource])) {
+            return [self::message(AdminRealtimeSummaries::DEFINITIONS[$resource][0], ['scope' => 'all'], AdminRealtimeSummaries::read($resource))];
+        }
         if ($resource === 'payment_orders') {
             return self::paymentOrders($id, $action);
         }

+ 58 - 0
app/Services/AdminRealtime/AdminRealtimeSummaries.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Services\AdminRealtime;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\admin\Online;
+use App\Http\Controllers\admin\PaymentOrder;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use RuntimeException;
+
+/** 全量统计按变化登记事件,覆盖跨仓库写入、在线平台变化及统计时间窗口到期。 */
+final class AdminRealtimeSummaries
+{
+    public const DEFINITIONS = [
+        'online_platform_count' => ['admin.stats.platform_count', Online::class, 'platformCount'],
+        'payment_unprocessed' => ['admin.stats.unprocessed', PaymentOrder::class, 'unProcessed'],
+    ];
+
+    public static function read(string $resource): array
+    {
+        [, $controller, $method] = self::DEFINITIONS[$resource];
+        // 复用 HTTP 端点本身,统计字段、筛选条件和时间窗口只有一个实现。
+        $response = app()->call([app()->make($controller), $method]);
+        $body = $response instanceof JsonResponse ? $response->getData(true) : [];
+        if (($body['code'] ?? null) !== HttpStatus::OK || !is_array($body['data'] ?? null)) {
+            throw new RuntimeException($controller . '@' . $method . ' failed: ' . ($body['msg'] ?? 'invalid response'));
+        }
+        return $body['data'];
+    }
+
+    public static function scan(): void
+    {
+        if (!config('admin_realtime.enabled')) return;
+        $interval = max(1, (int) config('admin_realtime.summary_scan_seconds', 5));
+        foreach (array_keys(self::DEFINITIONS) as $resource) {
+            try {
+                if (!Cache::add('admin_realtime:summary_scan:' . $resource, 1, $interval)) continue;
+                $signature = hash('sha256', json_encode(self::read($resource), JSON_THROW_ON_ERROR));
+                $key = 'admin_realtime:summary_snapshot:' . $resource;
+                if (Cache::get($key) === $signature) continue;
+
+                // 必须先确认事件登记成功再记住快照;失败或崩溃最多重复,不会吞掉变化。
+                if (!DB::table('admin_realtime_events')->insert([
+                    'resource' => $resource, 'resource_id' => '1', 'action' => 'updated',
+                    'source' => 'bot-28', 'created_at' => now('Asia/Shanghai')->format('Y-m-d H:i:s'),
+                ])) throw new RuntimeException('Summary event registration failed');
+                Cache::forever($key, $signature);
+            } catch (\Throwable $e) {
+                Log::channel('realtime')->error('admin_realtime_summary_scan_failed', [
+                    'resource' => $resource, 'error' => $e->getMessage(),
+                ]);
+            }
+        }
+    }
+}

+ 3 - 0
config/admin_realtime.php

@@ -37,6 +37,9 @@ return [
     // 在线状态扫描间隔(秒),由 relay 顺带执行
     'online_scan_seconds' => (int) env('ADMIN_WS_ONLINE_SCAN_SECONDS', 15),
 
+    // 在线平台人数和待处理角标的全量检查间隔;仅变化时登记并推送。
+    'summary_scan_seconds' => (int) env('ADMIN_WS_SUMMARY_SCAN_SECONDS', 5),
+
     // 事件表保留时长(小时),也是断线补发能回溯的最长时间
     'retention_hours' => (int) env('ADMIN_WS_RETENTION_HOURS', 72),
 ];

+ 1 - 1
docs/admin-online-register-api.md

@@ -19,7 +19,7 @@
 
 ## 一、在线汇总(菜单 uri:`onlineSummary`)
 
-前端两个 Tab 对应两个接口(**后端无 tab 参数**,由前端 Tab 切换调用不同 URL)。平台人数用专用接口轮询,**不要**挂在 `paymentOrder/unProcessed` 上。
+前端两个 Tab 对应两个接口(**后端无 tab 参数**,由前端 Tab 切换调用不同 URL)。平台人数使用专用接口加载初始值或降级查询,**不要**挂在 `paymentOrder/unProcessed` 上。实时更新可使用统计推送,接入见[后台实时推送前端文档](总后台/后台实时推送-前端接入.md#平台在线人数和待处理角标)。
 
 | 前端 Tab | 建议 tab 值(前端自用) | 接口 |
 |----------|-------------------------|------|

+ 6 - 1
docs/admin-websocket.md

@@ -11,7 +11,7 @@
 | 进程 | 命令 | 作用 |
 | --- | --- | --- |
 | Reverb | `php artisan reverb:start` | 维护 WebSocket 连接,把消息推给后台页面的 Laravel Echo 客户端 |
-| relay(中继) | `php artisan admin-realtime:relay` | 读取共享事件表,解析列表行并发给 Reverb;失败重试,同时处理登录到期断连和在线状态扫描 |
+| relay(中继) | `php artisan admin-realtime:relay` | 读取共享事件表,解析列表行和统计快照并发给 Reverb;失败重试,同时处理登录到期断连和在线状态扫描 |
 
 两者各运行一个实例,并由 Supervisor 守护。此次推送链路同步发送,不需要额外的队列 worker;项目原有支付、Telegram 等队列任务及其 worker 继续保留。
 
@@ -32,6 +32,7 @@
 ```dotenv
 ADMIN_WS_ENABLED=false
 ADMIN_WS_BROADCASTER=reverb
+ADMIN_WS_SUMMARY_SCAN_SECONDS=5
 
 # 前端 Echo 连接的公网地址:这里的 host 不带协议或路径
 ADMIN_WS_PUBLIC_HOST=admin.example.com
@@ -221,6 +222,8 @@ tail -n 50 storage/logs/realtime/reverb.log
 
 `/up` 返回 200、两个进程 `RUNNING` 只说明进程和本机服务就绪。还需用后台页面完成以下联调:
 
+可打开项目提供的 `https://API域名/realtime-test.html` 检查连接、授权、实时消息和补发;使用步骤见[前端接入文档的快速验证页面](总后台/后台实时推送-前端接入.md#快速验证页面)。测试页所在域名同样需要被 `REVERB_ALLOWED_ORIGINS` 允许。
+
 1. 已登录管理员请求 `GET /admin/realtime/config` 成功,公网 host/443/https 正确。
 2. 浏览器连接 `wss://域名/app/应用KEY?...` 获得 101,`POST /admin/broadcasting/auth` 授权 200。
 3. 操作一条后台订单,列表在不手动刷新的情况下更新;断网重连后能补发或重新拉取列表。
@@ -238,6 +241,8 @@ tail -n 50 storage/logs/realtime/reverb.log
 
 ## 11. 后端事件写入
 
+平台在线人数与待处理角标由 relay 每 `ADMIN_WS_SUMMARY_SCAN_SECONDS`(默认 5)秒复用原 HTTP 控制器的统计查询,仅在结果变化时登记 `online_platform_count` / `payment_unprocessed` 事件,再经现有的持久发送、重试和补发流程投递。无需新增迁移或在 melbet 写入点加钩子;同一用户切换平台、订单跨过 24 小时窗口、分享领取待审核数量变化都能检测。统计扫描失败只记录日志,恢复后重试,不把失败当作已推送。
+
 ### 11.1 bot-28 已有写入覆盖
 
 bot-28 内以下写入会产生事件:

+ 1 - 1
docs/前端交付/README.md

@@ -4,7 +4,7 @@
 
 ## 2026-10-31 增补
 
-- [后台实时推送-前端接入](bot-28/后台/后台实时推送-前端接入.md):待上线方案,使用 Laravel Echo + Reverb。包含连接参数、频道授权、14 个频道、事件格式、表格更新、断线补发与登录失效处理。
+- [后台实时推送-前端接入](bot-28/后台/后台实时推送-前端接入.md):待上线方案,使用 Laravel Echo + Reverb。包含连接参数、频道授权、16 个频道、事件格式、表格更新、断线补发与登录失效处理。
 - 下述原始范围保留;加上本次新增的1份正文,当前共22个来源文件。
 
 ## 本次范围

+ 40 - 2
docs/前端交付/bot-28/后台/后台实时推送-前端接入.md

@@ -4,6 +4,17 @@
 
 状态:待上线。
 
+## 快速验证页面
+
+部署项目的 `public` 文件后,打开 **`https://bot-28的API域名/realtime-test.html`**。页面和本地客户端依赖已随代码提供,无需 npm 构建或外部 CDN。
+
+1. 填写后台 API 地址和管理员 token(支持 `Bearer ` 前缀)。token 仅在页面内存中使用,刷新后清空。
+2. 点击「获取连接配置」,选择频道,再点击「连接 WebSocket」。
+3. 检查配置、握手、频道授权三项结果;在另一后台页面触发一条真实业务变更,收到实时消息后第四项才会通过。
+4. 可先断开、触发业务变更,再连接检查补发;「检查补发」也支持手动指定起点。仅收到历史补发不会被当作实时链路通过。
+
+如果测试页与正常后台页面的域名不同,Reverb 的 `REVERB_ALLOWED_ORIGINS` 需包含测试页域名;也可以将测试页和 `static` 依赖放在已允许的后台域名下。详情、消息和日志仅在当前页面显示;此页不创建订单或修改业务数据。
+
 ## 客户端选型
 
 **使用 Laravel Echo + `pusher-js` 连接 Laravel Reverb(Pusher 协议)。** 安装:
@@ -109,6 +120,8 @@ echo.private('admin.orders.withdraw')
 | `admin.orders.manual_audit` | `/admin/manualAudit` | `id` | 同上 |
 | `admin.users` | `/admin/user` | `id` | `.user.created` |
 | `admin.users.online` | `/admin/online/users` | `member_id` | `.user.online` `.user.offline` |
+| `admin.stats.platform_count` | `/admin/online/platformCount` | `scope` | `.stats.updated` |
+| `admin.stats.unprocessed` | `/admin/paymentOrder/unProcessed` | `scope` | `.stats.updated` |
 
 Echo 订阅私有频道时自动请求授权接口 `POST /admin/broadcasting/auth`:
 
@@ -147,7 +160,7 @@ Echo 回调收到的对象:
 | `channel` | 接收此消息的频道 |
 | `event` | 事件名称,例如 `order.created`;传给 Echo 的监听名称需在前面加 `.` |
 | `action` | `created` / `updated` / `deleted` / `online` / `offline` |
-| `key` | 行主键对象:订单和用户用 `id`,在线用户用 `member_id` |
+| `key` | 记录标识:订单和用户用 `id`,在线用户用 `member_id`,统计快照为 `{"scope":"all"}` |
 | `data` | 对应 HTTP 列表的单行对象;删除或下线时为 `null` |
 | `source` | 写入事件的服务名称,如 `bot-28`、`melbet_sport-api` |
 | `created_at` | 事件登记时间,Asia/Shanghai |
@@ -238,7 +251,7 @@ Echo 回调收到的对象:
   "key": { "member_id": "9001" }, "data": null, "source": "bot-28", "created_at": "2026-10-31 10:31:00" }
 ```
 
-在线判定与在线列表一致(存在未过期 `user_session`,或 30 分钟内活跃,不含虚拟用户)。relay 每 `ADMIN_WS_ONLINE_SCAN_SECONDS`(默认 15 秒)比对一次在线集合,所以上下线最多延迟一个扫描周期;「30 分钟未活跃自动下线」这类没有写入动作的变化也会推送。按端统计(`GET /admin/online/platformCount`)没有单独推送,收到上下线事件后可以节流(例如 10 秒一次)重新请求。
+在线判定与在线列表一致(存在未过期 `user_session`,或 30 分钟内活跃,不含虚拟用户)。relay 每 `ADMIN_WS_ONLINE_SCAN_SECONDS`(默认 15 秒)比对一次在线集合;按端统计另通过下方的 `admin.stats.platform_count` 频道推送,默认每 5 秒检查变化,包含同一在线用户切换平台而人数不变的情况。
 
 ### 其它频道的 `data`
 
@@ -259,6 +272,31 @@ Echo 回调收到的对象:
 
 字段含义与各列表接口文档一致,这里不重复。
 
+## 平台在线人数和待处理角标
+
+两个统计频道均监听 **`.stats.updated`**,`action` 为 `updated`、`key` 为 `{"scope":"all"}`,`data` 是对应 HTTP 接口的完整 `data` 对象。收到后直接替换统计对象,不逐项累加。
+
+| 频道 | `data` 字段(均为整数) |
+| --- | --- |
+| `admin.stats.platform_count` | `total`、`apple`、`android`、`pc`、`mobile`、`wechat`、`unknown` |
+| `admin.stats.unprocessed` | `usdtRecharge`、`rgRecharge`、`rmbRecharge`、`usdtWithdraw`、`rgWithdraw`、`rmbWithdraw`、`shareClaim` |
+
+平台统计的 `wechat` 已包含在 `mobile` 中。订单角标沿用原接口的近 24 小时口径,`shareClaim` 则统计全部未审核申请、不限申请日期。权限分别沿用 `admin/online/platformCount` 和 `admin/paymentOrder/unProcessed`。
+
+relay 默认每 5 秒读取这两项统计,仅在值变化时登记并推送,首次扫描也会发送快照。该机制覆盖跨仓库写入、在线平台变化、活跃及订单时间窗口到期;不依赖 HTTP 列表刷新触发。两频道同样支持 `seq` 去重和断线补发。
+
+```js
+realtime.watch('admin.stats.platform_count', ['stats.updated'], (event) => {
+  platformCounts = event.data;
+}, () => fetchPlatformCounts());
+
+realtime.watch('admin.stats.unprocessed', ['stats.updated'], (event) => {
+  pendingBadges = event.data;
+}, () => fetchPendingBadges());
+```
+
+首次进入后台仍请求相应 HTTP 接口加载初始值,之后使用推送更新;断线补发要求 `resync` 或推送不可用时,重新请求 HTTP 接口。
+
 ## 断线补发与重新同步
 
 ```http

+ 8 - 2
docs/前端交付/交付清单.json

@@ -252,8 +252,8 @@
       "role": "后台",
       "kind": "正文",
       "git_status": "??",
-      "source_sha256": "3d12e136a2b8e39716dd9069218d3bad198b1871f60a324632832325728db0b0",
-      "delivery_sha256": "7676bb5c67c047d75c8fed92d83c2d8b3ca4de3e7acf816a39155b61b1f771a0",
+      "source_sha256": "a3a4165b57b68690f4411473603767c818091f4d8d7637afb5022a323709351e",
+      "delivery_sha256": "5d3b1cf974a06a413dd8bb9cb3d9f35a9fcf2aa893928e8259c9386e3231d149",
       "revised_on": "2026-11-01"
     }
   ],
@@ -375,6 +375,12 @@
       "project": "bot-28",
       "branch": "upgrade/laravel-13",
       "description": "按用户更正标明实时推送方案待上线,移除已从 Node 网关迁移等表示生产已发布的措辞,同步前端交付副本与校验值。"
+    },
+    {
+      "date": "2026-11-01",
+      "project": "bot-28",
+      "branch": "master",
+      "description": "新增只读 WebSocket 浏览器检测页;新增平台在线人数和待处理角标两个统计频道,沿用 HTTP 返回与权限,按变化推送并支持 seq 补发;同步前端交付副本与校验值。"
     }
   ]
 }

+ 1 - 1
docs/后端/Laravel13升级说明.md

@@ -72,7 +72,7 @@ Git 忽略 lock 文件不代表 Composer 忽略它:已有 lock 时 `composer i
 
 本次待上线方案使用 Reverb 提供后台订单 / 用户列表推送,后端部署见 [admin-websocket.md](../admin-websocket.md),前端接入见[后台实时推送-前端接入](../总后台/后台实时推送-前端接入.md)。
 
-- **保留**:事件表 `bot_admin_realtime_events`(melbet_sport-api 继续写,无需改框架)、列表行解析、14 个频道与载荷 `data`、按钮权限口径。
+- **保留**:事件表 `bot_admin_realtime_events`(melbet_sport-api 继续写,无需改框架)、列表行解析、16 个频道与载荷 `data`、按钮权限口径。
 - **新增**:`config/reverb.php`、`config/broadcasting.php` 的 `reverb` 连接、常驻命令 `admin-realtime:relay`(读事件表、同步广播,不需要队列 worker)、迁移 `2026_10_31_210000_add_seq_to_admin_realtime_events`(连续 `seq` 用于补发)、`POST /admin/broadcasting/auth`、`GET /admin/realtime/config`、`GET /admin/realtime/replay`。
 - **分支内移除(尚未发布)**:`realtime/`(Node 网关)、`GET /admin/realtime/ticket`、内部接口 `GET /admin/realtime/events` 及 `ADMIN_WS_SECRET` / `ADMIN_WS_INTERNAL_SECRET` 等变量。
 - **前后端同步发布**:前端按接入文档使用 Echo 方案。

+ 40 - 2
docs/总后台/后台实时推送-前端接入.md

@@ -4,6 +4,17 @@
 
 状态:待上线。
 
+## 快速验证页面
+
+部署项目的 `public` 文件后,打开 **`https://bot-28的API域名/realtime-test.html`**。页面和本地客户端依赖已随代码提供,无需 npm 构建或外部 CDN。
+
+1. 填写后台 API 地址和管理员 token(支持 `Bearer ` 前缀)。token 仅在页面内存中使用,刷新后清空。
+2. 点击「获取连接配置」,选择频道,再点击「连接 WebSocket」。
+3. 检查配置、握手、频道授权三项结果;在另一后台页面触发一条真实业务变更,收到实时消息后第四项才会通过。
+4. 可先断开、触发业务变更,再连接检查补发;「检查补发」也支持手动指定起点。仅收到历史补发不会被当作实时链路通过。
+
+如果测试页与正常后台页面的域名不同,Reverb 的 `REVERB_ALLOWED_ORIGINS` 需包含测试页域名;也可以将测试页和 `static` 依赖放在已允许的后台域名下。详情、消息和日志仅在当前页面显示;此页不创建订单或修改业务数据。
+
 ## 客户端选型
 
 **使用 Laravel Echo + `pusher-js` 连接 Laravel Reverb(Pusher 协议)。** 安装:
@@ -109,6 +120,8 @@ echo.private('admin.orders.withdraw')
 | `admin.orders.manual_audit` | `/admin/manualAudit` | `id` | 同上 |
 | `admin.users` | `/admin/user` | `id` | `.user.created` |
 | `admin.users.online` | `/admin/online/users` | `member_id` | `.user.online` `.user.offline` |
+| `admin.stats.platform_count` | `/admin/online/platformCount` | `scope` | `.stats.updated` |
+| `admin.stats.unprocessed` | `/admin/paymentOrder/unProcessed` | `scope` | `.stats.updated` |
 
 Echo 订阅私有频道时自动请求授权接口 `POST /admin/broadcasting/auth`:
 
@@ -147,7 +160,7 @@ Echo 回调收到的对象:
 | `channel` | 接收此消息的频道 |
 | `event` | 事件名称,例如 `order.created`;传给 Echo 的监听名称需在前面加 `.` |
 | `action` | `created` / `updated` / `deleted` / `online` / `offline` |
-| `key` | 行主键对象:订单和用户用 `id`,在线用户用 `member_id` |
+| `key` | 记录标识:订单和用户用 `id`,在线用户用 `member_id`,统计快照为 `{"scope":"all"}` |
 | `data` | 对应 HTTP 列表的单行对象;删除或下线时为 `null` |
 | `source` | 写入事件的服务名称,如 `bot-28`、`melbet_sport-api` |
 | `created_at` | 事件登记时间,Asia/Shanghai |
@@ -238,7 +251,7 @@ Echo 回调收到的对象:
   "key": { "member_id": "9001" }, "data": null, "source": "bot-28", "created_at": "2026-10-31 10:31:00" }
 ```
 
-在线判定与在线列表一致(存在未过期 `user_session`,或 30 分钟内活跃,不含虚拟用户)。relay 每 `ADMIN_WS_ONLINE_SCAN_SECONDS`(默认 15 秒)比对一次在线集合,所以上下线最多延迟一个扫描周期;「30 分钟未活跃自动下线」这类没有写入动作的变化也会推送。按端统计(`GET /admin/online/platformCount`)没有单独推送,收到上下线事件后可以节流(例如 10 秒一次)重新请求。
+在线判定与在线列表一致(存在未过期 `user_session`,或 30 分钟内活跃,不含虚拟用户)。relay 每 `ADMIN_WS_ONLINE_SCAN_SECONDS`(默认 15 秒)比对一次在线集合;按端统计另通过下方的 `admin.stats.platform_count` 频道推送,默认每 5 秒检查变化,包含同一在线用户切换平台而人数不变的情况。
 
 ### 其它频道的 `data`
 
@@ -259,6 +272,31 @@ Echo 回调收到的对象:
 
 字段含义与各列表接口文档一致,这里不重复。
 
+## 平台在线人数和待处理角标
+
+两个统计频道均监听 **`.stats.updated`**,`action` 为 `updated`、`key` 为 `{"scope":"all"}`,`data` 是对应 HTTP 接口的完整 `data` 对象。收到后直接替换统计对象,不逐项累加。
+
+| 频道 | `data` 字段(均为整数) |
+| --- | --- |
+| `admin.stats.platform_count` | `total`、`apple`、`android`、`pc`、`mobile`、`wechat`、`unknown` |
+| `admin.stats.unprocessed` | `usdtRecharge`、`rgRecharge`、`rmbRecharge`、`usdtWithdraw`、`rgWithdraw`、`rmbWithdraw`、`shareClaim` |
+
+平台统计的 `wechat` 已包含在 `mobile` 中。订单角标沿用原接口的近 24 小时口径,`shareClaim` 则统计全部未审核申请、不限申请日期。权限分别沿用 `admin/online/platformCount` 和 `admin/paymentOrder/unProcessed`。
+
+relay 默认每 5 秒读取这两项统计,仅在值变化时登记并推送,首次扫描也会发送快照。该机制覆盖跨仓库写入、在线平台变化、活跃及订单时间窗口到期;不依赖 HTTP 列表刷新触发。两频道同样支持 `seq` 去重和断线补发。
+
+```js
+realtime.watch('admin.stats.platform_count', ['stats.updated'], (event) => {
+  platformCounts = event.data;
+}, () => fetchPlatformCounts());
+
+realtime.watch('admin.stats.unprocessed', ['stats.updated'], (event) => {
+  pendingBadges = event.data;
+}, () => fetchPendingBadges());
+```
+
+首次进入后台仍请求相应 HTTP 接口加载初始值,之后使用推送更新;断线补发要求 `resync` 或推送不可用时,重新请求 HTTP 接口。
+
 ## 断线补发与重新同步
 
 ```http

+ 1 - 0
example.env

@@ -133,6 +133,7 @@ ADMIN_WS_PUBLIC_PORT=443
 ADMIN_WS_PUBLIC_SCHEME=https
 ADMIN_WS_POLL_MS=1000
 ADMIN_WS_ONLINE_SCAN_SECONDS=15
+ADMIN_WS_SUMMARY_SCAN_SECONDS=5
 ADMIN_WS_RETENTION_HOURS=72
 
 # Reverb(Laravel 官方 WebSocket 服务)。KEY/SECRET 用 openssl rand -hex 16 生成,SECRET 不得下发前端

+ 98 - 0
public/realtime-test.html

@@ -0,0 +1,98 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="referrer" content="no-referrer">
+  <title>实时推送检测 · bot-28</title>
+  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='9' fill='%230f766e'/%3E%3Cpath d='M6 17h5l3-8 4 15 3-7h5' fill='none' stroke='white' stroke-width='2'/%3E%3C/svg%3E">
+  <link rel="stylesheet" href="./static/css/realtime-test.css">
+  <script defer src="./static/vendor/realtime/pusher-8.4.0.min.js"></script>
+  <script defer src="./static/vendor/realtime/laravel-echo-2.1.6.js"></script>
+  <script type="module" src="./static/js/realtime-test.js"></script>
+</head>
+<body>
+  <div class="page">
+    <header class="page-header">
+      <div>
+        <div class="eyebrow"><span class="brand-mark" aria-hidden="true">↗</span> BOT-28 <span class="eyebrow-divider">/</span> REALTIME CHECK</div>
+        <h1>实时推送检测</h1>
+        <p class="intro">从连接到收到消息,逐步确认后台推送是否正常。</p>
+      </div>
+      <div id="connection-pill" class="pill" role="status"><span class="dot"></span><span id="connection-label">未连接</span></div>
+    </header>
+
+    <main>
+      <div id="global-error" class="notice error" role="alert" hidden></div>
+      <noscript><div class="notice error">此页面需要启用 JavaScript。</div></noscript>
+      <div class="workspace">
+        <section class="card setup-card" aria-labelledby="setup-title">
+          <div class="section-heading"><span class="section-number">01</span><h2 id="setup-title">连接设置</h2></div>
+          <form id="config-form">
+            <label for="api-base">后台 API 地址</label>
+            <input id="api-base" type="url" inputmode="url" placeholder="https://api.example.com" autocomplete="url" required>
+            <p class="field-help">填写 bot-28 的 API 地址,通常与 WebSocket 公网域名相同。</p>
+            <label for="admin-token">管理员 token</label>
+            <div class="token-field">
+              <input id="admin-token" type="password" placeholder="粘贴后台登录 token" autocomplete="off" spellcheck="false" required>
+              <button id="toggle-token" class="icon-button" type="button" aria-label="显示 token" aria-pressed="false">显示</button>
+            </div>
+            <p class="field-help">支持带 Bearer 前缀。本页不会保存 token,刷新后清空。</p>
+            <button id="load-config" class="button primary full-width" type="submit">获取连接配置 <span aria-hidden="true">→</span></button>
+          </form>
+
+          <div id="config-summary" class="config-summary" hidden>
+            <div class="summary-topline"><span class="dot green"></span> 已读取后端配置</div>
+            <dl>
+              <div><dt>WebSocket</dt><dd id="ws-address">—</dd></div>
+              <div><dt>当前序号</dt><dd id="config-seq">—</dd></div>
+              <div><dt>登录到期</dt><dd id="session-expiry">—</dd></div>
+            </dl>
+            <details><summary>查看公开连接参数</summary><pre id="config-json"></pre></details>
+          </div>
+          <div class="connection-actions">
+            <button id="connect" class="button primary" type="button" disabled>连接 WebSocket</button>
+            <button id="disconnect" class="button secondary" type="button" disabled>断开</button>
+          </div>
+          <p class="small-note">只读取配置、授权和消息,不创建订单或修改业务数据。建议通过 API 域名下的本页地址打开;本页域名需在 REVERB_ALLOWED_ORIGINS 中允许。</p>
+        </section>
+
+        <div class="right-column">
+          <section class="card progress-card" aria-labelledby="progress-title">
+            <div class="section-heading"><span class="section-number">02</span><h2 id="progress-title">检测结果</h2><span id="progress-count" class="muted small">0 / 4 通过</span></div>
+            <ol class="checks">
+              <li id="check-api" data-status="idle"><span class="check-icon">1</span><div><strong>读取配置</strong><span id="check-api-note">等待获取后台配置</span></div><span class="check-state">待检查</span></li>
+              <li id="check-ws" data-status="idle"><span class="check-icon">2</span><div><strong>WebSocket 连接</strong><span id="check-ws-note">等待连接 Reverb</span></div><span class="check-state">待检查</span></li>
+              <li id="check-auth" data-status="idle"><span class="check-icon">3</span><div><strong>频道授权与订阅</strong><span id="check-auth-note">使用当前管理员的频道权限</span></div><span class="check-state">待检查</span></li>
+              <li id="check-live" data-status="idle"><span class="check-icon">4</span><div><strong>接收实时事件</strong><span id="check-live-note">连接后,在另一后台页面触发一条真实业务变更</span></div><span class="check-state">待验证</span></li>
+            </ol>
+            <p id="verdict" class="verdict">连接成功仅代表通道可用;收到实时事件后,才能确认业务 → relay → Reverb 的链路。</p>
+          </section>
+
+          <section class="card channels-card" aria-labelledby="channels-title">
+            <div class="section-heading"><span class="section-number">03</span><h2 id="channels-title">订阅频道</h2><span id="channel-count" class="muted small">尚未读取</span></div>
+            <div class="channel-toolbar"><div><button id="select-all" class="text-button" type="button" disabled>全选</button><span class="muted">/</span><button id="select-none" class="text-button" type="button" disabled>清空选择</button></div><button id="apply-channels" class="button secondary compact" type="button" disabled>应用订阅</button></div>
+            <div id="channel-list" class="channel-list"><div class="empty compact-empty">读取配置后,在这里选择有权限的频道。</div></div>
+            <div class="replay-controls">
+              <div><label for="after-seq">补发起点 <span class="optional">选填</span></label><input id="after-seq" type="number" min="0" step="1" placeholder="留空:按各频道续传点"></div>
+              <button id="replay" class="button secondary" type="button" disabled>检查补发</button>
+            </div>
+            <p class="field-help">可先断开、触发业务变更,再连接检查补发。补发历史消息不会被误记为实时链路通过。</p>
+          </section>
+        </div>
+      </div>
+
+      <section class="card events-card" aria-labelledby="events-title">
+        <div class="section-heading event-heading"><span class="section-number">04</span><h2 id="events-title">消息记录</h2><div class="event-totals"><span>实时 <strong id="live-count">0</strong></span><span>补发 <strong id="replay-count">0</strong></span><span>重复 <strong id="duplicate-count">0</strong></span></div></div>
+        <div class="event-toolbar"><div class="segmented" role="group" aria-label="消息来源筛选"><button type="button" data-filter="all" aria-pressed="true">全部</button><button type="button" data-filter="live" aria-pressed="false">实时</button><button type="button" data-filter="replay" aria-pressed="false">补发</button></div><button id="clear-events" class="text-button" type="button">清空记录</button></div>
+        <div class="table-scroll"><table><thead><tr><th>收到时间</th><th>来源</th><th>频道 / 事件</th><th>SEQ</th><th>记录主键</th><th><span class="sr-only">详情</span></th></tr></thead><tbody id="event-rows"></tbody></table></div>
+        <div id="events-empty" class="empty"><span class="empty-signal" aria-hidden="true">⌁</span><strong>等待第一条消息</strong><span>完成订阅后,消息将在这里出现。</span></div>
+        <details id="event-detail" class="event-detail" hidden><summary>消息详情 <span id="detail-meta"></span></summary><pre id="event-json"></pre></details>
+      </section>
+
+      <section class="card logs-card" aria-labelledby="logs-title"><div class="section-heading"><h2 id="logs-title">连接日志</h2><button id="clear-logs" class="text-button" type="button">清空日志</button></div><ol id="logs" aria-live="polite" aria-relevant="additions"><li class="log-entry muted"><time>—</time><span>等待开始检测。</span></li></ol></section>
+    </main>
+    <footer><span><span class="dot"></span> bot-28 · Reverb + Laravel Echo</span><span>仅用于连接联调 · 最多保留最近 200 条消息</span></footer>
+  </div>
+</body>
+</html>

+ 792 - 0
public/static/css/realtime-test.css

@@ -0,0 +1,792 @@
+:root {
+  color-scheme:light;
+  --ink:#172b35;
+  --muted:#71818b;
+  --line:#e4e9ed;
+  --bg:#f4f7f8;
+  --green:#087a69;
+  --green-soft:#e6f5ef;
+  --red:#b53f42;
+  --amber:#92671a;
+  font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
+  font-size:14px;
+}
+* {
+  box-sizing:border-box;
+}
+body {
+  margin:0;
+  background:var(--bg);
+  color:var(--ink);
+}
+button,input {
+  font:inherit;
+}
+button {
+  cursor:pointer;
+}
+button:disabled {
+  cursor:not-allowed;
+  opacity:.45;
+}
+button,input,summary {
+  touch-action:manipulation;
+}
+button:focus-visible,input:focus-visible,summary:focus-visible {
+  outline:3px solid #55baa5;
+  outline-offset:3px;
+}
+input {
+  width:100%;
+  height:45px;
+  border:1px solid #d9e2e7;
+  border-radius:8px;
+  padding:0 12px;
+  color:var(--ink);
+  background:#fff;
+  transition:border-color .15s;
+}
+input:focus {
+  border-color:var(--green);
+  outline:none;
+  box-shadow:0 0 0 3px #087a6910;
+}
+input::placeholder {
+  color:#a0acb4;
+}
+label {
+  display:block;
+  font-weight:600;
+  font-size:13px;
+  margin:0 0 8px;
+}
+pre {
+  font:12px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace;
+  white-space:pre-wrap;
+  overflow-wrap:anywhere;
+}
+h1,h2,p {
+  margin:0;
+}
+h1 {
+  font-size:32px;
+  font-weight:650;
+  letter-spacing:-1px;
+  margin:17px 0 10px;
+}
+h2 {
+  font-size:16px;
+  font-weight:650;
+}
+.page {
+  max-width:1264px;
+  margin:auto;
+  padding:38px 32px 24px;
+}
+.page-header {
+  display:flex;
+  justify-content:space-between;
+  align-items:center;
+  margin-bottom:28px;
+}
+.eyebrow {
+  display:flex;
+  align-items:center;
+  gap:10px;
+  font:600 11px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace;
+  letter-spacing:1.5px;
+  color:#647d83;
+}
+.brand-mark {
+  display:grid;
+  place-items:center;
+  height:25px;
+  width:25px;
+  background:var(--green);
+  color:white;
+  border-radius:7px;
+  font-size:20px;
+  letter-spacing:0;
+}
+.eyebrow-divider {
+  color:#b5c3c7;
+  margin:0 3px;
+}
+.intro {
+  font-size:14px;
+  color:var(--muted);
+  line-height:1.65;
+}
+.pill {
+  display:flex;
+  align-items:center;
+  gap:8px;
+  border:1px solid #dce5e8;
+  background:#fff;
+  border-radius:24px;
+  padding:10px 15px;
+  font-size:12px;
+  font-weight:600;
+}
+.dot {
+  display:inline-block;
+  width:7px;
+  height:7px;
+  border-radius:50%;
+  background:#9daeb5;
+  flex:none;
+}
+.green,.pill[data-status=connected] .dot {
+  background:var(--green);
+}
+.pill[data-status=connected] {
+  border-color:#b9ded0;
+  color:var(--green);
+  background:#f1faf5;
+}
+.pill[data-status=connecting] .dot {
+  background:#bc8c35;
+  animation:pulse 1.3s infinite;
+}
+.pill[data-status=failed],.pill[data-status=unavailable] {
+  color:var(--red);
+  border-color:#e7c5c5;
+}
+.workspace {
+  display:grid;
+  grid-template-columns:360px minmax(0,1fr);
+  gap:22px;
+  align-items:start;
+}
+.card {
+  background:#fff;
+  border:1px solid var(--line);
+  border-radius:13px;
+  box-shadow:0 3px 12px #16303c03;
+  padding:24px;
+}
+.section-heading {
+  display:flex;
+  align-items:center;
+  gap:9px;
+  margin-bottom:22px;
+}
+.section-heading .small {
+  margin-left:auto;
+}
+.section-number {
+  color:#93a3aa;
+  font:12px ui-monospace,SFMono-Regular,Consolas,monospace;
+}
+.muted {
+  color:var(--muted);
+}
+.small {
+  font-size:12px;
+}
+.field-help {
+  font-size:12px;
+  color:var(--muted);
+  line-height:1.7;
+  margin:8px 0 20px;
+}
+.token-field {
+  position:relative;
+}
+.token-field input {
+  padding-right:56px;
+}
+.icon-button {
+  border:0;
+  background:none;
+  position:absolute;
+  right:7px;
+  top:8px;
+  padding:6px;
+  color:var(--green);
+  font-size:12px;
+}
+.button {
+  border:1px solid transparent;
+  border-radius:8px;
+  padding:11px 15px;
+  display:inline-flex;
+  justify-content:center;
+  align-items:center;
+  gap:8px;
+  white-space:nowrap;
+  font-weight:600;
+  font-size:13px;
+  min-height:42px;
+}
+.primary {
+  background:var(--green);
+  color:white;
+}
+.primary:hover:not(:disabled) {
+  background:#066758;
+}
+.secondary {
+  background:#fff;
+  border-color:#dce4e8;
+  color:#3c5661;
+}
+.secondary:hover:not(:disabled) {
+  background:#f4f7f8;
+}
+.full-width {
+  width:100%;
+  justify-content:space-between;
+}
+.connection-actions {
+  display:grid;
+  grid-template-columns:1fr 70px;
+  gap:9px;
+  margin-top:22px;
+}
+.small-note {
+  font-size:11px;
+  line-height:1.8;
+  color:#8a999f;
+  margin-top:15px;
+}
+.right-column {
+  display:grid;
+  gap:22px;
+}
+.checks {
+  list-style:none;
+  padding:0;
+  margin:0;
+}
+.checks li {
+  display:flex;
+  align-items:center;
+  gap:13px;
+  padding:14px 0;
+  border-bottom:1px solid #f0f3f5;
+}
+.checks li:first-child {
+  padding-top:0;
+}
+.checks li:last-child {
+  border-bottom:0;
+  padding-bottom:0;
+}
+.check-icon {
+  width:30px;
+  height:30px;
+  border:1px solid #dfe7eb;
+  color:#92a3ab;
+  border-radius:50%;
+  display:grid;
+  place-items:center;
+  font:12px ui-monospace,SFMono-Regular,Consolas,monospace;
+  flex:none;
+}
+.checks strong {
+  display:block;
+  font-size:13px;
+  font-weight:600;
+  margin-bottom:5px;
+}
+.checks li div span {
+  font-size:12px;
+  color:#8999a0;
+  line-height:1.6;
+  display:block;
+}
+.check-state {
+  margin-left:auto;
+  color:#9babb2;
+  font-size:11px;
+  white-space:nowrap;
+}
+.checks [data-status=ok] .check-icon {
+  color:var(--green);
+  background:var(--green-soft);
+  border-color:transparent;
+}
+.checks [data-status=ok] .check-state {
+  color:var(--green);
+}
+.checks [data-status=loading] .check-icon {
+  color:var(--amber);
+  background:#fff8e7;
+  border-color:transparent;
+}
+.checks [data-status=error] .check-icon {
+  color:var(--red);
+  background:#fceeee;
+  border-color:transparent;
+}
+.checks [data-status=error] .check-state {
+  color:var(--red);
+}
+.checks [data-status=warning] .check-state {
+  color:var(--amber);
+}
+.verdict {
+  font-size:12px;
+  color:#758e92;
+  line-height:1.8;
+  background:#f4f9f8;
+  border-radius:8px;
+  padding:12px 14px;
+  margin-top:21px;
+}
+.verdict[data-status=ok] {
+  color:#146b56;
+  background:#eaf6ee;
+}
+.config-summary {
+  margin-top:22px;
+  padding-top:19px;
+  border-top:1px solid var(--line);
+}
+.summary-topline {
+  display:flex;
+  align-items:center;
+  gap:7px;
+  font-size:12px;
+  color:var(--green);
+  font-weight:600;
+}
+.config-summary dl {
+  margin:13px 0;
+}
+.config-summary dl div {
+  display:flex;
+  gap:10px;
+  align-items:baseline;
+  font-size:11px;
+  margin-top:11px;
+}
+.config-summary dt {
+  flex:none;
+  color:var(--muted);
+  width:65px;
+}
+.config-summary dd {
+  margin:0;
+  color:#38505b;
+  overflow-wrap:anywhere;
+  font-family:ui-monospace,SFMono-Regular,Consolas,monospace;
+}
+.config-summary pre {
+  background:#f5f8f9;
+  padding:12px;
+  border-radius:6px;
+  max-height:250px;
+  overflow:auto;
+  margin-bottom:0;
+}
+summary {
+  font-size:12px;
+  color:var(--muted);
+  cursor:pointer;
+}
+.channel-toolbar,.event-toolbar {
+  display:flex;
+  align-items:center;
+  justify-content:space-between;
+  gap:12px;
+  margin-bottom:15px;
+}
+.channel-toolbar>div {
+  display:flex;
+  align-items:center;
+  gap:8px;
+}
+.text-button {
+  border:0;
+  background:none;
+  color:var(--green);
+  padding:4px 0;
+  font-size:12px;
+}
+.text-button:hover:not(:disabled) {
+  text-decoration:underline;
+}
+.compact {
+  min-height:32px;
+  padding:6px 10px;
+  font-size:12px;
+}
+.channel-list {
+  display:grid;
+  grid-template-columns:repeat(2,minmax(0,1fr));
+  gap:8px;
+  max-height:236px;
+  overflow:auto;
+  padding:1px;
+}
+.channel {
+  display:flex;
+  align-items:center;
+  gap:9px;
+  border:1px solid #e5ecef;
+  border-radius:7px;
+  padding:10px;
+  min-width:0;
+  margin:0;
+  font-size:12px;
+  font-weight:400;
+  cursor:pointer;
+}
+.channel:has(input:checked) {
+  background:#f4faf7;
+  border-color:#d6e8df;
+}
+.channel input {
+  width:14px;
+  height:14px;
+  accent-color:var(--green);
+  flex:none;
+  margin:0;
+}
+.channel .channel-name {
+  overflow:hidden;
+  text-overflow:ellipsis;
+  white-space:nowrap;
+}
+.channel-status {
+  margin-left:auto;
+  font-size:10px;
+  color:#96a7ae;
+  white-space:nowrap;
+}
+.channel-status[data-status=subscribed] {
+  color:var(--green);
+}
+.channel-status[data-status=denied],.channel-status[data-status=error] {
+  color:var(--red);
+}
+.replay-controls {
+  display:flex;
+  align-items:end;
+  gap:10px;
+  border-top:1px solid #eef2f4;
+  padding-top:15px;
+  margin-top:16px;
+}
+.replay-controls>div {
+  flex:1;
+}
+.replay-controls input {
+  height:38px;
+  font-size:12px;
+}
+.replay-controls label {
+  font-size:12px;
+}
+.optional {
+  font-weight:400;
+  color:#a0adb4;
+  font-size:11px;
+  margin-left:6px;
+}
+.replay-controls button {
+  min-height:38px;
+}
+.channels-card .field-help {
+  margin-bottom:0;
+}
+.events-card {
+  margin-top:22px;
+  padding-bottom:12px;
+}
+.event-totals {
+  margin-left:auto;
+  display:flex;
+  gap:20px;
+  color:var(--muted);
+  font-size:12px;
+}
+.event-totals strong {
+  font-variant-numeric:tabular-nums;
+  font-size:16px;
+  color:#304c57;
+  font-weight:600;
+  margin-left:5px;
+}
+.segmented {
+  display:flex;
+  gap:2px;
+  background:#f1f5f6;
+  padding:3px;
+  border-radius:7px;
+}
+.segmented button {
+  border:0;
+  border-radius:5px;
+  background:transparent;
+  color:#83959e;
+  padding:6px 16px;
+  font-size:12px;
+}
+.segmented [aria-pressed=true] {
+  color:#1f6257;
+  background:#fff;
+  box-shadow:0 1px 4px #142e3810;
+}
+.table-scroll {
+  overflow-x:auto;
+}
+table {
+  border-collapse:collapse;
+  width:100%;
+  text-align:left;
+  font-size:12px;
+}
+th {
+  color:#899aa2;
+  font-weight:400;
+  font-size:11px;
+  padding:11px 10px;
+  background:#f8fafb;
+  border-bottom:1px solid var(--line);
+  white-space:nowrap;
+}
+th:first-child {
+  border-radius:7px 0 0;
+}
+th:last-child {
+  border-radius:0 7px 0 0;
+}
+td {
+  padding:13px 10px;
+  border-bottom:1px solid #edf2f4;
+  white-space:nowrap;
+}
+td .subline {
+  display:block;
+  color:#91a0a6;
+  margin-top:5px;
+  font-size:11px;
+}
+td.seq {
+  font-family:ui-monospace,SFMono-Regular,Consolas,monospace;
+}
+.tag {
+  display:inline-block;
+  padding:3px 7px;
+  border-radius:4px;
+  background:#e8f5ee;
+  color:#228160;
+  font-size:10px;
+}
+.tag.replay {
+  background:#eef1fa;
+  color:#6578ad;
+}
+.tag.duplicate {
+  background:#fff6e5;
+  color:#a17a2e;
+  margin-left:4px;
+}
+tr.selected td {
+  background:#f5faf8;
+}
+.empty {
+  padding:35px 16px;
+  display:flex;
+  flex-direction:column;
+  align-items:center;
+  gap:10px;
+  color:#97a5ad;
+  font-size:12px;
+  line-height:1.7;
+  text-align:center;
+}
+.empty strong {
+  color:#758991;
+  font-size:13px;
+  font-weight:500;
+}
+.empty-signal {
+  font-size:34px;
+  line-height:1;
+  color:#b1c9c4;
+}
+.compact-empty {
+  grid-column:1/-1;
+  padding:24px;
+  font-size:12px;
+}
+.event-detail {
+  margin:16px 0 5px;
+  border:1px solid var(--line);
+  border-radius:8px;
+  padding:14px;
+  background:#f8fafb;
+}
+.event-detail summary {
+  color:#34525d;
+}
+#detail-meta {
+  font-size:11px;
+  color:#8b9ba2;
+  margin-left:10px;
+}
+.event-detail pre {
+  max-height:350px;
+  overflow:auto;
+  margin-bottom:0;
+}
+.logs-card {
+  margin-top:22px;
+  padding-bottom:14px;
+}
+.logs-card .section-heading {
+  margin-bottom:12px;
+}
+.logs-card .text-button {
+  margin-left:auto;
+}
+.logs-card h2 {
+  font-size:14px;
+}
+#logs {
+  margin:0;
+  padding:0;
+  list-style:none;
+  max-height:230px;
+  overflow:auto;
+  scrollbar-width:thin;
+}
+.log-entry {
+  padding:9px 0;
+  border-bottom:1px solid #f0f3f5;
+  display:flex;
+  align-items:baseline;
+  gap:14px;
+  font-size:12px;
+  line-height:1.7;
+  overflow-wrap:anywhere;
+}
+.log-entry:last-child {
+  border:0;
+}
+.log-entry time {
+  font:11px ui-monospace,SFMono-Regular,Consolas,monospace;
+  color:#9cabb1;
+  flex:none;
+  width:66px;
+}
+.log-entry[data-level=error]>span {
+  color:var(--red);
+}
+.log-entry[data-level=warning]>span {
+  color:var(--amber);
+}
+.log-entry[data-level=success]>span {
+  color:#3d8069;
+}
+.notice {
+  border:1px solid #edcece;
+  border-radius:9px;
+  padding:13px 16px;
+  font-size:13px;
+  line-height:1.7;
+  margin-bottom:20px;
+}
+.notice.error {
+  background:#fff3f1;
+  color:#a84541;
+}
+footer {
+  display:flex;
+  justify-content:space-between;
+  align-items:center;
+  gap:15px;
+  color:#9ba9b0;
+  font-size:11px;
+  padding:24px 2px 0;
+}
+footer>span:first-child {
+  display:flex;
+  gap:7px;
+  align-items:center;
+}
+.sr-only {
+  position:absolute;
+  width:1px;
+  height:1px;
+  overflow:hidden;
+  clip-path:inset(50%);
+}
+[hidden] {
+  display:none!important;
+}
+@keyframes pulse {
+  50% {
+    opacity:.35;
+  }
+}
+@media(max-width:960px) {
+  .workspace {
+    grid-template-columns:320px minmax(0,1fr);
+  }
+  .card {
+    padding:20px;
+  }
+  .channel-list {
+    grid-template-columns:1fr;
+  }
+  .event-totals {
+    gap:12px;
+  }
+}
+@media(max-width:760px) {
+  .page {
+    padding:24px 18px;
+  }
+  .workspace {
+    grid-template-columns:1fr;
+  }
+  .page-header {
+    align-items:flex-start;
+  }
+  .pill {
+    margin-top:6px;
+    padding:8px 11px;
+  }
+  h1 {
+    font-size:28px;
+  }
+  .intro {
+    max-width:250px;
+    font-size:13px;
+  }
+  .channel-list {
+    grid-template-columns:repeat(2,minmax(0,1fr));
+  }
+  .event-heading {
+    flex-wrap:wrap;
+  }
+  .event-totals {
+    width:100%;
+    margin:3px 0 0 24px;
+  }
+  .checks li div span {
+    max-width:250px;
+  }
+  footer {
+    align-items:flex-start;
+    flex-direction:column;
+    gap:9px;
+  }
+  .section-heading {
+    margin-bottom:18px;
+  }
+  .setup-card .small-note {
+    max-width:500px;
+  }
+}
+@media(prefers-reduced-motion:reduce) {
+  *,*::before,*::after {
+    animation:none!important;
+    transition:none!important;
+  }
+}

+ 354 - 0
public/static/js/realtime-probe.js

@@ -0,0 +1,354 @@
+export class ProbeError extends Error {
+  constructor(message, status = 0) { super(message); this.status = status; }
+}
+
+export function apiAddress(value) {
+  let url;
+  try { url = new URL(value.trim()); } catch { throw new ProbeError('请填写完整 API 地址,例如 https://api.example.com'); }
+  if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
+    throw new ProbeError('API 地址只允许 http/https 域名和路径,不要包含账号、查询参数或 #。');
+  }
+  return url.href.replace(/\/+$/, '');
+}
+
+export function apiEndpoint(base, path) {
+  if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//') || path.includes('\\')) {
+    throw new ProbeError('接口路径无效,请检查后端配置。');
+  }
+  const url = new URL(`${base}${path}`);
+  if (url.origin !== new URL(base).origin) throw new ProbeError('授权接口必须与填写的 API 地址同源。');
+  return url.href;
+}
+
+export function redact(value, token = '', depth = 0) {
+  if (depth > 8) return '[内容层级过深]';
+  if (typeof value === 'string') {
+    return (token ? value.split(token).join('[已隐藏 token]') : value).replace(/Bearer\s+[^\s"<>]+/gi, 'Bearer [已隐藏]');
+  }
+  if (Array.isArray(value)) return value.slice(0, 100).map((v) => redact(v, token, depth + 1));
+  if (value && typeof value === 'object') {
+    return Object.fromEntries(Object.entries(value).map(([key, v]) => [key,
+      /authorization|password|secret|token|ticket/i.test(key) ? '[已隐藏]' : redact(v, token, depth + 1)]));
+  }
+  return value;
+}
+
+export async function requestJSON(url, { method = 'GET', token, body, signal } = {}) {
+  let response;
+  try {
+    response = await fetch(url, {
+      method, signal, credentials: 'omit', cache: 'no-store', redirect: 'error',
+      headers: { Accept: 'application/json', Authorization: `Bearer ${token}`,
+        ...(body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}) },
+      ...(body ? { body: new URLSearchParams(body) } : {}),
+    });
+  } catch (error) {
+    if (error.name === 'AbortError') throw error;
+    throw new ProbeError('请求未能完成:请检查 API 地址、HTTPS 证书和跨域配置。');
+  }
+  let data;
+  try { data = await response.json(); } catch {
+    throw new ProbeError(`接口未返回 JSON(HTTP ${response.status}),请检查 API 地址和站点转发配置。`, response.status);
+  }
+  if (!response.ok) throw new ProbeError(data.msg || data.message || `HTTP ${response.status}`, response.status);
+  return data;
+}
+
+const EVENTS = ['order.created', 'order.updated', 'order.deleted', 'user.created', 'user.online', 'user.offline', 'stats.updated'];
+
+export class RealtimeProbe {
+  constructor({ Echo, Pusher, request = requestJSON, onChange = () => {}, onLog = () => {}, onEvent = () => {} }) {
+    Object.assign(this, { Echo, Pusher, request, onChange, onLog, onEvent });
+    this.epoch = 0;
+    this.session = 0;
+    this.controllers = new Set();
+    this.channels = new Map();
+    this.seen = new Set();
+    this.state = this.emptyState();
+    this.token = '';
+  }
+
+  emptyState() {
+    return { connection: 'disconnected', api: 'idle', config: null, socketId: '',
+      liveCount: 0, replayCount: 0, duplicateCount: 0, lastSeq: 0, expired: false, channels: [] };
+  }
+
+  emit() {
+    this.state.channels = [...this.channels.values()].map(({ name, status, checkpoint, liveSeq, pendingAfter, replaying }) =>
+      ({ name, status, checkpoint, liveSeq, pendingAfter, replaying }));
+    this.onChange(this.state);
+  }
+
+  log(level, message, details) {
+    this.onLog({ level, message: redact(message, this.token), details: redact(details, this.token), time: Date.now() });
+  }
+
+  invalidate() {
+    this.disconnect({ quiet: true });
+    this.session++;
+    this.token = '';
+    this.channels.clear();
+    this.seen.clear();
+    this.state = this.emptyState();
+    this.emit();
+  }
+
+  clearCounts() {
+    this.state.liveCount = this.state.replayCount = this.state.duplicateCount = 0;
+    this.emit();
+  }
+
+  async get(path, options = {}) {
+    const controller = new AbortController();
+    this.controllers.add(controller);
+    const timeout = setTimeout(() => controller.abort(), 15000);
+    try {
+      return await this.request(apiEndpoint(this.base, path), { token: this.token, signal: controller.signal, ...options });
+    } finally {
+      clearTimeout(timeout);
+      this.controllers.delete(controller);
+    }
+  }
+
+  disconnect({ quiet = false } = {}) {
+    this.epoch++;
+    for (const channel of this.channels.values()) {
+      channel.pendingAfter = Math.min(channel.pendingAfter ?? channel.checkpoint, channel.checkpoint);
+      channel.request = null;
+      channel.replaying = false;
+      channel.status = 'idle';
+    }
+    for (const controller of this.controllers) controller.abort();
+    this.controllers.clear();
+    const echo = this.echo;
+    this.echo = null;
+    echo?.disconnect();
+    this.state.connection = 'disconnected';
+    this.state.socketId = '';
+    if (!quiet) this.log('info', '已断开连接;续传点已保留,可再次连接验证断线补发。');
+    this.emit();
+  }
+
+  async configure(base, token) {
+    this.disconnect({ quiet: true });
+    this.session++;
+    const session = this.session;
+    this.channels.clear();
+    this.seen.clear();
+    this.token = '';
+    this.state = this.emptyState();
+    this.state.api = 'loading';
+    this.emit();
+    this.log('info', '正在读取后台实时推送配置。');
+    try {
+      this.base = apiAddress(base);
+      this.token = token.trim().replace(/^Bearer\s+/i, '').trim();
+      if (!this.token || /\s/.test(this.token)) throw new ProbeError('请填写有效的后台管理员 token,可带 Bearer 前缀。');
+      const response = await this.get('/admin/realtime/config');
+      if (session !== this.session) return null;
+      if (response.code !== 0) throw new ProbeError(response.msg || '后台未返回成功状态,请检查 token 或推送开关。');
+      const config = response.data;
+      if (!config || typeof config.key !== 'string' || !config.key || typeof config.ws_host !== 'string' || !config.ws_host
+          || !Array.isArray(config.channels) || !config.channels.every((c) => typeof c === 'string')
+          || !Number.isInteger(config.ws_port) || config.ws_port < 1 || config.ws_port > 65535
+          || typeof config.force_tls !== 'boolean' || !Number.isSafeInteger(config.last_seq) || config.last_seq < 0) {
+        throw new ProbeError('连接参数不完整,请检查 REVERB_APP_KEY、ADMIN_WS_PUBLIC_HOST / PORT / SCHEME。');
+      }
+      const wsURL = new URL(`${config.force_tls ? 'wss' : 'ws'}://${config.ws_host}:${config.ws_port}`);
+      if (wsURL.username || wsURL.password || wsURL.pathname !== '/' || wsURL.search || wsURL.hash) {
+        throw new ProbeError('WebSocket host 应只填写域名,不带协议或路径。');
+      }
+      apiEndpoint(this.base, config.auth_endpoint);
+      this.state.config = {
+        key: config.key, ws_host: config.ws_host, ws_port: config.ws_port, force_tls: config.force_tls,
+        auth_endpoint: config.auth_endpoint, last_seq: config.last_seq,
+        session_expires_at: config.session_expires_at || null,
+        channels: [...new Set(config.channels)],
+      };
+      this.state.lastSeq = config.last_seq;
+      this.state.api = 'ok';
+      this.log('success', `配置读取成功,当前账号可订阅 ${this.state.config.channels.length} 个频道。`);
+      this.emit();
+      return this.state.config;
+    } catch (error) {
+      if (session !== this.session) return null;
+      this.state.api = 'error';
+      this.log('error', error.name === 'AbortError' ? '配置请求已取消或超时,请重试。' : error.message);
+      this.emit();
+      throw error;
+    }
+  }
+
+  connect(names) {
+    if (!this.state.config || !this.token) throw new ProbeError('请先获取连接配置。');
+    if (this.state.expired) throw new ProbeError('登录已过期,请更新 token 后重新获取配置。');
+    if (typeof this.Echo !== 'function' || typeof this.Pusher !== 'function') throw new ProbeError('本地客户端文件加载失败,请检查 static/vendor/realtime 文件是否已上传。');
+    if (globalThis.location?.protocol === 'https:' && !this.state.config.force_tls) {
+      throw new ProbeError('当前页面是 HTTPS,但后端配置了 ws。请将 ADMIN_WS_PUBLIC_SCHEME 配置为 https。');
+    }
+    names = [...new Set(names)].filter((name) => this.state.config.channels.includes(name));
+    if (!names.length) throw new ProbeError('请至少选择一个有权限的频道。');
+    this.disconnect({ quiet: true });
+    const epoch = this.epoch;
+    const c = this.state.config;
+    this.state.connection = 'connecting';
+    this.echo = new this.Echo({
+      broadcaster: 'reverb', Pusher: this.Pusher, key: c.key,
+      wsHost: c.ws_host, wsPort: c.ws_port, wssPort: c.ws_port,
+      forceTLS: c.force_tls, enabledTransports: ['ws', 'wss'], enableStats: false, disableStats: true,
+      authorizer: (channel) => ({ authorize: (socketId, callback) => this.authorize(channel.name, socketId, callback, epoch) }),
+    });
+    const echo = this.echo;
+    echo.connector.pusher.connection.bind('state_change', ({ current }) => {
+      if (this.echo !== echo || epoch !== this.epoch) return;
+      this.state.connection = current;
+      if (current === 'connected') {
+        this.state.socketId = echo.socketId() || '';
+        this.log('success', 'WebSocket 握手成功。接下来检查频道授权。');
+      } else {
+        for (const channel of this.channels.values()) {
+          channel.pendingAfter = Math.min(channel.pendingAfter ?? channel.checkpoint, channel.checkpoint);
+          channel.request = null;
+          channel.replaying = false;
+          channel.status = 'idle';
+        }
+        this.log(current === 'failed' || current === 'unavailable' ? 'error' : 'info', `连接状态:${current}`);
+      }
+      this.emit();
+    });
+    echo.connector.pusher.connection.bind('error', (error) => {
+      if (this.echo === echo) this.log('error', 'WebSocket 连接错误,请检查域名、Nginx、证书和 Reverb 进程。', error);
+    });
+    this.subscribe(names);
+    this.emit();
+  }
+
+  async authorize(channelName, socketId, callback, epoch) {
+    const name = channelName.replace(/^private-/, '');
+    const channel = this.channels.get(name);
+    if (channel) channel.status = 'authorizing';
+    this.emit();
+    try {
+      const result = await this.get(this.state.config.auth_endpoint, { method: 'POST', body: { socket_id: socketId, channel_name: channelName } });
+      if (epoch !== this.epoch || this.echo?.socketId() !== socketId) return;
+      if (typeof result.auth !== 'string' || !result.auth) throw new ProbeError('授权接口缺少 auth 字段,请检查请求是否到达正确的 API。');
+      callback(null, result);
+    } catch (error) {
+      if (epoch !== this.epoch || this.echo?.socketId() !== socketId) return;
+      if (channel) channel.status = error.status === 403 ? 'denied' : 'error';
+      const message = error.status === 401 ? '登录已过期或 token 无效,请重新登录并更新 token。'
+        : error.status === 403 ? '频道授权被拒绝,请检查账号权限或后台推送开关。' : error.message;
+      this.log('error', `${name}:${message}`);
+      callback(error, null);
+      if (error.status === 401) { this.state.expired = true; this.disconnect({ quiet: true }); }
+      this.emit();
+    }
+  }
+
+  subscribe(names) {
+    if (!this.echo) throw new ProbeError('请先连接 WebSocket。');
+    const allowed = this.state.config.channels;
+    const wanted = new Set(names.filter((name) => allowed.includes(name)));
+    for (const [name, channel] of this.channels) {
+      if (!wanted.has(name)) {
+        this.echo.leave(name);
+        channel.active = false;
+        channel.binding = null;
+        channel.status = 'idle'; channel.request = null; channel.replaying = false;
+      }
+    }
+    const echo = this.echo;
+    const epoch = this.epoch;
+    for (const name of wanted) {
+      let channel = this.channels.get(name);
+      if (channel?.binding?.echo === echo && channel.active) continue;
+      if (!channel) {
+        channel = { name, checkpoint: this.state.config.last_seq, liveSeq: 0, pendingAfter: this.state.config.last_seq };
+        this.channels.set(name, channel);
+      }
+      channel.status = 'subscribing';
+      channel.active = true;
+      const sub = echo.private(name);
+      const binding = { echo, sub };
+      channel.binding = binding;
+      EVENTS.forEach((event) => sub.listen(`.${event}`, (message) => {
+        if (this.echo !== echo || epoch !== this.epoch || !channel.active || channel.binding !== binding) return;
+        if (message.channel !== name) { this.log('warning', '消息频道与订阅频道不一致,未计入检测结果。'); return; }
+        channel.liveSeq = Math.max(channel.liveSeq, message.seq || 0);
+        if (channel.pendingAfter === null) channel.checkpoint = Math.max(channel.checkpoint, channel.liveSeq);
+        this.receive(message, 'live');
+      }));
+      sub.subscribed(() => {
+        if (this.echo !== echo || epoch !== this.epoch || !channel.active || channel.binding !== binding) return;
+        channel.status = 'subscribed';
+        this.log('success', `${name} 订阅成功。`);
+        this.emit();
+        this.replay([name]);
+      });
+      sub.error((error) => {
+        if (this.echo !== echo || epoch !== this.epoch || !channel.active || channel.binding !== binding) return;
+        channel.status = error.status === 403 ? 'denied' : 'error';
+        this.emit();
+      });
+    }
+    this.emit();
+  }
+
+  async replay(names, after = null) {
+    if (!this.state.config) throw new ProbeError('请先获取配置。');
+    if (after !== null && (!Number.isSafeInteger(after) || after < 0)) throw new ProbeError('补发起点必须是大于等于 0 的整数。');
+    const jobs = names.map(async (name) => {
+      const channel = this.channels.get(name);
+      if (!channel || channel.replaying) return;
+      const request = {};
+      channel.request = request;
+      channel.replaying = true;
+      const session = this.session;
+      const epoch = this.epoch;
+      const current = () => this.session === session && this.epoch === epoch && channel.request === request;
+      const from = after ?? channel.pendingAfter ?? channel.checkpoint;
+      this.emit();
+      try {
+        const query = new URLSearchParams({ after_seq: String(from), channels: name });
+        const response = await this.get(`/admin/realtime/replay?${query}`);
+        if (!current()) return;
+        if (response.code !== 0) throw new ProbeError(response.msg || '补发请求失败');
+        const data = response.data;
+        if (!data || !Number.isSafeInteger(data.last_seq) || !Array.isArray(data.events)) throw new ProbeError('补发响应格式不正确。');
+        if (data.resync) {
+          this.log('warning', `${name} 需要重新加载业务列表(${data.reason || 'history_unavailable'})。本页继续检查实时消息。`);
+        } else {
+          for (const event of data.events) if (event.channel === name) this.receive(event, 'replay');
+          this.log('success', `${name} 补发检查完成:起点 ${from},返回 ${data.events.length} 条。`);
+        }
+        channel.checkpoint = Math.max(data.last_seq, channel.liveSeq);
+        channel.pendingAfter = null;
+      } catch (error) {
+        if (!current()) return;
+        this.log('error', `${name} 补发失败:${error.name === 'AbortError' ? '请求超时或已取消' : error.message}`);
+      } finally {
+        if (current()) { channel.replaying = false; channel.request = null; this.emit(); }
+      }
+    });
+    await Promise.all(jobs);
+  }
+
+  receive(event, source) {
+    if (!event || !Number.isSafeInteger(event.seq) || event.seq <= 0 || !this.state.config.channels.includes(event.channel)) {
+      this.log('warning', '收到格式不符合预期的事件,未计入检测结果。');
+      return;
+    }
+    const key = `${event.seq}:${event.channel}`;
+    const duplicate = this.seen.has(key);
+    if (duplicate) this.state.duplicateCount++;
+    else {
+      this.seen.add(key);
+      if (this.seen.size > 5000) this.seen.delete(this.seen.values().next().value);
+    }
+    if (source === 'live') this.state.liveCount++;
+    else this.state.replayCount++;
+    this.state.lastSeq = Math.max(this.state.lastSeq, event.seq);
+    this.onEvent({ source, duplicate, time: Date.now(), event: redact(event, this.token) });
+    this.emit();
+  }
+}

+ 224 - 0
public/static/js/realtime-test.js

@@ -0,0 +1,224 @@
+import { RealtimeProbe, redact } from './realtime-probe.js';
+
+const $ = (id) => document.getElementById(id);
+const LABELS = {
+  'admin.orders.sport': '体彩订单', 'admin.orders.lhc': '六合彩 / 彩票',
+  'admin.orders.sport_game': '足球游戏订单', 'admin.orders.jisu_game': '极速游戏订单',
+  'admin.orders.third_game': '三方游戏订单', 'admin.orders.bet': '投注记录',
+  'admin.orders.recharge': 'USDT 充值', 'admin.orders.payment': '代收订单',
+  'admin.orders.third_party_deposit': '三方充值', 'admin.orders.payment_withdraw': '人民币提现 / 代付',
+  'admin.orders.withdraw': 'USDT 提现', 'admin.orders.manual_audit': '人工上下分',
+  'admin.users': '用户列表', 'admin.users.online': '在线用户',
+  'admin.stats.platform_count': '平台在线人数', 'admin.stats.unprocessed': '待处理角标',
+};
+const STATE_LABELS = { initialized: '已初始化', connecting: '正在连接', connected: '已连接',
+  disconnected: '未连接', unavailable: '暂不可用', failed: '连接失败' };
+const CHANNEL_STATES = { idle: '待订阅', subscribing: '订阅中', authorizing: '授权中',
+  subscribed: '已订阅', denied: '无权限', error: '失败' };
+const time = (timestamp) => new Date(timestamp).toLocaleTimeString('zh-CN', { hour12: false });
+const make = (tag, text, className) => {
+  const element = document.createElement(tag);
+  if (text !== undefined) element.textContent = text;
+  if (className) element.className = className;
+  return element;
+};
+const pretty = (value, maximum = 32768) => {
+  const text = JSON.stringify(value, null, 2) ?? '';
+  return text.length > maximum ? `${text.slice(0, maximum)}\n… 内容较长,已截断显示。` : text;
+};
+
+let records = [];
+let eventFilter = 'all';
+let recordId = 0;
+let shownConfig = null;
+let latestState;
+const selected = new Set();
+
+const probe = new RealtimeProbe({ Echo: window.Echo?.default || window.Echo, Pusher: window.Pusher,
+  onChange: renderState, onLog: addLog, onEvent: addEvent });
+
+function showError(error) {
+  $('global-error').textContent = redact(error.message || String(error), probe.token);
+  $('global-error').hidden = false;
+}
+
+function check(id, status, note, label) {
+  const element = $(`check-${id}`);
+  element.dataset.status = status;
+  $(`check-${id}-note`).textContent = note;
+  element.querySelector('.check-state').textContent = label;
+  element.querySelector('.check-icon').textContent = status === 'ok' ? '✓' : status === 'error' ? '!' : { api: '1', ws: '2', auth: '3', live: '4' }[id];
+}
+
+function renderState(state) {
+  latestState = state;
+  if (state.config !== shownConfig) {
+    shownConfig = state.config;
+    selected.clear();
+    $('channel-list').replaceChildren();
+    if (shownConfig) {
+      for (const name of shownConfig.channels) {
+        selected.add(name);
+        const label = make('label', undefined, 'channel');
+        label.title = name;
+        const input = make('input'); input.type = 'checkbox'; input.checked = true; input.dataset.channel = name;
+        input.setAttribute('aria-label', LABELS[name] || name);
+        input.addEventListener('change', () => { input.checked ? selected.add(name) : selected.delete(name); renderControls(); });
+        const title = make('span', LABELS[name] || name, 'channel-name');
+        const status = make('span', '待订阅', 'channel-status'); status.dataset.channelStatus = name;
+        label.append(input, title, status); $('channel-list').append(label);
+      }
+      if (!shownConfig.channels.length) $('channel-list').append(make('div', '当前管理员没有可订阅的频道,请检查账号的列表权限。', 'empty compact-empty'));
+      const scheme = shownConfig.force_tls ? 'wss' : 'ws';
+      $('ws-address').textContent = `${scheme}://${shownConfig.ws_host}:${shownConfig.ws_port}/app/…`;
+      $('config-seq').textContent = String(shownConfig.last_seq);
+      $('session-expiry').textContent = shownConfig.session_expires_at
+        ? new Date(shownConfig.session_expires_at * 1000).toLocaleString('zh-CN', { hour12: false }) : '后端未提供';
+      $('config-json').textContent = pretty(shownConfig);
+    } else $('channel-list').append(make('div', '读取配置后,在这里选择有权限的频道。', 'empty compact-empty'));
+    $('config-summary').hidden = !shownConfig;
+  }
+  $('connection-pill').dataset.status = state.expired ? 'failed' : state.connection;
+  $('connection-label').textContent = state.expired ? '登录已过期' : STATE_LABELS[state.connection] || state.connection;
+  $('live-count').textContent = String(state.liveCount);
+  $('replay-count').textContent = String(state.replayCount);
+  $('duplicate-count').textContent = String(state.duplicateCount);
+  if (shownConfig) $('config-seq').textContent = String(state.lastSeq);
+  for (const element of document.querySelectorAll('[data-channel-status]')) {
+    const channel = state.channels.find((c) => c.name === element.dataset.channelStatus);
+    element.dataset.status = channel?.status || 'idle';
+    element.textContent = channel?.replaying ? '补发中' : CHANNEL_STATES[channel?.status || 'idle'];
+    element.title = channel ? `续传点 ${channel.checkpoint} · 最近实时 SEQ ${channel.liveSeq}` : '';
+  }
+  check('api', state.api, state.api === 'ok' ? '配置接口已返回有效的连接参数'
+    : state.api === 'loading' ? '正在请求后台配置…' : state.api === 'error' ? '请查看上方提示或连接日志' : '等待获取后台配置',
+    { ok: '通过', loading: '检查中', error: '失败', idle: '待检查' }[state.api]);
+  const connected = state.connection === 'connected';
+  const failed = ['failed', 'unavailable'].includes(state.connection);
+  check('ws', connected ? 'ok' : failed ? 'error' : state.connection === 'connecting' ? 'loading' : 'idle',
+    connected ? `握手成功 · socket ${state.socketId}` : failed ? '检查公网域名、Nginx 反代、证书及 Reverb 进程' : '等待连接 Reverb',
+    connected ? '通过' : failed ? '失败' : state.connection === 'connecting' ? '连接中' : '待检查');
+  check('live', state.liveCount ? 'ok' : 'idle', state.liveCount ? `本次检测已收到 ${state.liveCount} 条实时消息`
+    : state.replayCount ? '已收到补发消息,仍需等待新的实时事件' : '连接后,在另一后台页面触发一条真实业务变更', state.liveCount ? '通过' : '待验证');
+  renderControls();
+}
+
+function renderControls() {
+  const state = latestState;
+  if (!state) return;
+  const ready = state.api === 'ok';
+  const connected = state.connection === 'connected';
+  const subscribed = state.channels.filter((c) => selected.has(c.name) && c.status === 'subscribed').length;
+  const denied = state.channels.some((c) => selected.has(c.name) && ['denied', 'error'].includes(c.status));
+  const authorizing = state.channels.some((c) => selected.has(c.name) && ['authorizing', 'subscribing'].includes(c.status));
+  check('auth', state.expired ? 'error' : subscribed && subscribed === selected.size ? 'ok' : denied ? 'error' : authorizing ? 'loading' : 'idle',
+    state.expired ? '登录已过期,请更新 token 后重新获取配置' : subscribed ? `${subscribed} / ${selected.size} 个已选频道订阅成功`
+      : denied ? '频道授权未通过,请查看连接日志' : '使用当前管理员的频道权限',
+    state.expired ? '登录失效' : subscribed && subscribed === selected.size ? '通过' : denied ? '失败' : authorizing ? '授权中' : '待检查');
+  $('load-config').disabled = state.api === 'loading';
+  $('load-config').firstChild.textContent = state.api === 'loading' ? '正在读取配置 ' : '获取连接配置 ';
+  $('connect').disabled = !ready || !selected.size || state.expired;
+  $('connect').textContent = probe.echo ? '重新连接' : '连接 WebSocket';
+  $('disconnect').disabled = !probe.echo;
+  $('apply-channels').disabled = !connected;
+  $('select-all').disabled = $('select-none').disabled = !ready;
+  $('replay').disabled = !ready || !selected.size || !state.channels.some((c) => selected.has(c.name)) || state.expired
+    || state.channels.some((c) => selected.has(c.name) && c.replaying);
+  $('channel-count').textContent = ready ? `已选 ${selected.size} / ${state.config.channels.length}` : '尚未读取';
+  const passed = Number(ready) + Number(connected) + Number(subscribed > 0 && subscribed === selected.size) + Number(state.liveCount > 0);
+  $('progress-count').textContent = `${passed} / 4 通过`;
+  const complete = passed === 4;
+  $('verdict').dataset.status = complete ? 'ok' : '';
+  $('verdict').textContent = complete ? '已收到实时消息,当前 API → 频道授权 → relay / Reverb → 浏览器链路验证通过。'
+    : state.expired ? '登录已过期。重新登录后粘贴新 token,再获取配置。'
+      : connected && subscribed ? '连接和订阅已通过。在另一后台页面触发一条业务变更,等待实时消息。'
+        : '连接成功仅代表通道可用;收到实时事件后,才能确认业务 → relay → Reverb 的链路。';
+}
+
+function addLog(entry) {
+  const logs = $('logs');
+  if (!logs.querySelector('[data-level]')) logs.replaceChildren();
+  const follow = logs.scrollHeight - logs.scrollTop - logs.clientHeight < 50;
+  const row = make('li', undefined, 'log-entry'); row.dataset.level = entry.level;
+  const message = entry.details ? `${entry.message} ${pretty(entry.details, 1200)}` : entry.message;
+  row.append(make('time', time(entry.time)), make('span', message)); logs.append(row);
+  while (logs.children.length > 120) logs.firstChild.remove();
+  if (follow) logs.scrollTop = logs.scrollHeight;
+}
+
+function addEvent(message) {
+  const event = message.event;
+  records.unshift({ ...message, event: { channel: event.channel, event: event.event, action: event.action, seq: event.seq,
+    key: event.key }, json: pretty(event), recordId: ++recordId });
+  records = records.slice(0, 200);
+  renderEvents();
+}
+
+function renderEvents() {
+  $('event-rows').replaceChildren();
+  const visible = records.filter((record) => eventFilter === 'all' || record.source === eventFilter);
+  $('events-empty').hidden = visible.length > 0;
+  for (const record of visible) {
+    const e = record.event;
+    const row = make('tr'); row.dataset.recordId = String(record.recordId);
+    row.append(make('td', time(record.time)));
+    const source = make('td'); source.append(make('span', record.source === 'live' ? '实时' : '补发', `tag ${record.source}`));
+    if (record.duplicate) source.append(make('span', '重复', 'tag duplicate'));
+    row.append(source);
+    const channel = make('td', LABELS[e.channel] || e.channel);
+    channel.append(make('span', e.event, 'subline')); channel.title = e.channel; row.append(channel);
+    row.append(make('td', String(e.seq), 'seq'), make('td', JSON.stringify(e.key)));
+    const action = make('td'); const button = make('button', '查看详情', 'text-button'); button.type = 'button';
+    button.addEventListener('click', () => {
+      for (const other of $('event-rows').children) other.classList.toggle('selected', other === row);
+      $('event-detail').hidden = false; $('event-detail').open = true;
+      $('detail-meta').textContent = `SEQ ${e.seq} · ${e.channel}`;
+      $('event-json').textContent = record.json;
+    });
+    action.append(button); row.append(action); $('event-rows').append(row);
+  }
+}
+
+function clearEvents() {
+  records = []; $('event-detail').hidden = true; $('event-json').textContent = ''; renderEvents();
+}
+
+$('config-form').addEventListener('submit', async (event) => {
+  event.preventDefault(); $('global-error').hidden = true; clearEvents();
+  try { await probe.configure($('api-base').value, $('admin-token').value); } catch (error) { showError(error); }
+});
+for (const id of ['api-base', 'admin-token']) $(id).addEventListener('input', () => {
+  $('global-error').hidden = true;
+  probe.invalidate(); clearEvents();
+});
+$('toggle-token').addEventListener('click', () => {
+  const show = $('admin-token').type === 'password'; $('admin-token').type = show ? 'text' : 'password';
+  $('toggle-token').textContent = show ? '隐藏' : '显示';
+  $('toggle-token').setAttribute('aria-label', show ? '隐藏 token' : '显示 token');
+  $('toggle-token').setAttribute('aria-pressed', String(show));
+});
+$('connect').addEventListener('click', () => { $('global-error').hidden = true; try { probe.connect([...selected]); } catch (error) { showError(error); } });
+$('disconnect').addEventListener('click', () => probe.disconnect());
+$('apply-channels').addEventListener('click', () => { try { probe.subscribe([...selected]); } catch (error) { showError(error); } });
+$('replay').addEventListener('click', async () => {
+  $('global-error').hidden = true;
+  try { await probe.replay([...selected], $('after-seq').value === '' ? null : Number($('after-seq').value)); } catch (error) { showError(error); }
+});
+for (const [id, value] of [['select-all', true], ['select-none', false]]) $(id).addEventListener('click', () => {
+  selected.clear();
+  for (const checkbox of document.querySelectorAll('[data-channel]')) {
+    checkbox.checked = value; if (value) selected.add(checkbox.dataset.channel);
+  }
+  renderControls();
+});
+for (const button of document.querySelectorAll('[data-filter]')) button.addEventListener('click', () => {
+  eventFilter = button.dataset.filter;
+  for (const other of document.querySelectorAll('[data-filter]')) other.setAttribute('aria-pressed', String(other === button));
+  renderEvents();
+});
+$('clear-events').addEventListener('click', () => { clearEvents(); probe.clearCounts(); });
+$('clear-logs').addEventListener('click', () => { $('logs').replaceChildren(); addLog({ level: 'info', message: '日志已清空。', time: Date.now() }); });
+window.addEventListener('beforeunload', () => probe.disconnect({ quiet: true }));
+if (['http:', 'https:'].includes(location.protocol)) $('api-base').value = location.origin;
+probe.emit();
+if (location.protocol === 'file:') addLog({ level: 'warning', message: '建议将页面部署到 API 域名下打开。本地 file 页面可能被 Reverb 的来源限制拦截。', time: Date.now() });

+ 44 - 0
public/static/vendor/realtime/LICENSES.txt

@@ -0,0 +1,44 @@
+Laravel Echo 2.1.6
+The MIT License (MIT)
+
+Copyright (c) Taylor Otwell
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+Pusher JS 8.4.0
+Copyright (c) 2020 Pusher, Ltd (https://pusher.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/static/vendor/realtime/laravel-echo-2.1.6.js


Разница между файлами не показана из-за своего большого размера
+ 7 - 0
public/static/vendor/realtime/pusher-8.4.0.min.js


+ 156 - 0
tests/Integration/AdminRealtimeSummaryTest.php

@@ -0,0 +1,156 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\Online;
+use App\Http\Controllers\admin\PaymentOrder;
+use App\Services\AdminRealtime\AdminRealtimeFeed;
+use App\Services\AdminRealtime\AdminRealtimeSummaries;
+use App\Services\OnlineUserService;
+use Carbon\Carbon;
+use Illuminate\Cache\ArrayStore;
+use Illuminate\Cache\Repository as Cache;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Events\Dispatcher;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use PHPUnit\Framework\TestCase;
+
+class AdminRealtimeSummaryTest extends TestCase
+{
+    private Application $app;
+    private Carbon $clock;
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances(); Model::clearBootedModels();
+        $this->clock = Carbon::now('Asia/Shanghai'); Carbon::setTestNow($this->clock);
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'],
+            'admin_realtime' => ['enabled' => true, 'summary_scan_seconds' => 5, 'online_scan_seconds' => 15]]));
+        $this->app->instance('events', new Dispatcher($this->app));
+        $this->app->instance('cache', new Cache(new ArrayStore()));
+        $this->app->instance('translator', new Translator(new ArrayLoader(), 'zh'));
+        $this->app->instance('request', Request::create('/admin/realtime/replay'));
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
+        $this->app->instance('log', new class {
+            public array $records = [];
+            public function channel($name) { return $this; }
+            public function error($message, $context = []) { $this->records[] = [$message, $context]; }
+        });
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setEventDispatcher($this->app['events']); $db->setAsGlobal(); $db->bootEloquent();
+        $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn() => $db->schema());
+        // 只在隔离 SQLite 中提供本服务所用排序规则,执行原始统计 SQL 而非替换统计服务。
+        DB::connection()->getPdo()->sqliteCreateCollation('utf8mb4_general_ci', 'strcasecmp');
+        foreach (['2026_10_31_160000_create_admin_realtime_events.php', '2026_10_31_210000_add_seq_to_admin_realtime_events.php',
+            '2026_11_01_000000_add_delivery_state_to_admin_realtime_events.php'] as $file) {
+            (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
+        }
+        Schema::create('users', function (Blueprint $t) {
+            $t->id(); $t->string('user_id'); $t->string('member_id'); $t->integer('from')->default(1); $t->integer('last_active_time');
+        });
+        Schema::create('user_session', function (Blueprint $t) { $t->id(); $t->string('user_id'); $t->integer('expire_time'); });
+        Schema::create('user_login', function (Blueprint $t) { $t->id(); $t->string('user_id'); $t->string('platform'); $t->integer('status')->default(1); });
+        foreach (['recharges', 'withdraws', 'payment_orders'] as $table) {
+            Schema::create($table, function (Blueprint $t) use ($table) {
+                $t->id(); $t->integer('status'); $t->dateTime('created_at');
+                if ($table === 'payment_orders') $t->integer('type');
+            });
+        }
+        Schema::create('share_claims', function (Blueprint $t) { $t->id(); $t->string('status'); });
+        DB::table('users')->insert(['user_id' => 'account1', 'member_id' => '1001', 'last_active_time' => time()]);
+        DB::table('user_login')->insert(['user_id' => 'account1', 'platform' => 'iOS', 'status' => 1]);
+        $now = date('Y-m-d H:i:s');
+        DB::table('recharges')->insert(['status' => 0, 'created_at' => $now]);
+        DB::table('withdraws')->insert(['status' => 0, 'created_at' => $now]);
+        foreach ([[3, 0], [3, 1], [3, 5], [1, 0], [1, 1], [4, 0], [2, 0], [1, 6]] as [$type, $status]) {
+            DB::table('payment_orders')->insert(compact('type', 'status') + ['created_at' => $now]);
+        }
+        DB::table('share_claims')->insert([['status' => 'pending'], ['status' => 'pending'], ['status' => 'approved']]);
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect(); Facade::clearResolvedInstances(); Carbon::setTestNow(); parent::tearDown();
+    }
+
+    private function tick(int $seconds): array
+    {
+        Carbon::setTestNow($this->clock->copy()->addSeconds($seconds));
+        $batch = AdminRealtimeFeed::next(AdminRealtimeFeed::startCursor());
+        AdminRealtimeFeed::acknowledge($batch['ids']);
+        return $batch['events'];
+    }
+
+    public function test_initial_snapshots_match_http_responses_and_support_replay(): void
+    {
+        $events = $this->tick(0);
+        $this->assertSame(['admin.stats.platform_count', 'admin.stats.unprocessed'], array_column($events, 'channel'));
+        $this->assertSame(['stats.updated', 'stats.updated'], array_column($events, 'event'));
+        $this->assertSame([['scope' => 'all'], ['scope' => 'all']], array_column($events, 'key'));
+        $this->assertSame((new Online())->platformCount()->getData(true)['data'], $events[0]['data']);
+        $this->assertSame((new PaymentOrder())->unProcessed()->getData(true)['data'], $events[1]['data']);
+        $this->assertSame(['usdtRecharge' => 1, 'rgRecharge' => 3, 'rmbRecharge' => 2, 'usdtWithdraw' => 1,
+            'rgWithdraw' => 1, 'rmbWithdraw' => 1, 'shareClaim' => 2], $events[1]['data']);
+        $replay = AdminRealtimeFeed::replay(0, ['admin.stats.unprocessed']);
+        $this->assertSame(1, count($replay['events']));
+        $this->assertSame($events[1]['data'], $replay['events'][0]['data']);
+        $this->assertSame([], $this->tick(6), '统计值未变,不重复推送');
+    }
+
+    public function test_platform_switch_is_detected_even_when_online_user_ids_do_not_change(): void
+    {
+        $this->tick(0); $before = OnlineUserService::onlineUserIds();
+        DB::table('user_login')->insert(['user_id' => 'account1', 'platform' => 'Android', 'status' => 1]);
+        $events = $this->tick(6);
+        $this->assertSame($before, OnlineUserService::onlineUserIds());
+        $this->assertCount(1, $events);
+        $this->assertSame('admin.stats.platform_count', $events[0]['channel']);
+        $this->assertSame(0, $events[0]['data']['apple']);
+        $this->assertSame(1, $events[0]['data']['android']);
+        $this->assertSame([], $this->tick(12));
+        DB::table('users')->update(['last_active_time' => time() - 1900]);
+        $events = $this->tick(18);
+        $summary = array_values(array_filter($events, fn($e) => $e['channel'] === 'admin.stats.platform_count'));
+        $this->assertSame(0, $summary[0]['data']['total'], '活跃窗口过期时归零');
+    }
+
+    public function test_pending_badges_track_raw_writes_claim_audits_and_aged_orders(): void
+    {
+        $this->tick(0);
+        // 不经过 Eloquent 或事件登记,模拟共享数据库另一应用的写入。
+        DB::table('payment_orders')->where('type', 3)->update(['status' => 2]);
+        DB::table('share_claims')->where('id', 1)->update(['status' => 'rejected']);
+        DB::table('recharges')->update(['created_at' => date('Y-m-d H:i:s', time() - 90000)]);
+        $events = $this->tick(6);
+        $this->assertCount(1, $events);
+        $this->assertSame('admin.stats.unprocessed', $events[0]['channel']);
+        $this->assertSame(0, $events[0]['data']['rgRecharge']);
+        $this->assertSame(0, $events[0]['data']['usdtRecharge']);
+        $this->assertSame(1, $events[0]['data']['shareClaim']);
+        $this->assertSame((new PaymentOrder())->unProcessed()->getData(true)['data'], $events[0]['data']);
+        $this->assertSame(8, DB::table('payment_orders')->count(), '扫描只读业务表');
+    }
+
+    public function test_failed_outbox_registration_does_not_swallow_the_snapshot_change(): void
+    {
+        Schema::rename('admin_realtime_events', 'unavailable_realtime_events');
+        AdminRealtimeSummaries::scan();
+        $this->assertNotEmpty($this->app['log']->records);
+        Schema::rename('unavailable_realtime_events', 'admin_realtime_events');
+        $events = $this->tick(6);
+        $this->assertCount(2, $events, '事件表恢复后仍会登记两个初始快照');
+        $this->assertSame([], $this->tick(12));
+    }
+}

+ 7 - 0
tests/Integration/AdminRealtimeTest.php

@@ -257,6 +257,13 @@ class AdminRealtimeTest extends TestCase
         $this->assertSame(403, $call($token, 'private-admin.orders.unknown')->getStatusCode());
         $this->assertSame(403, $call($token, 'admin.orders.withdraw')->getStatusCode(), '只授权私有频道');
         $this->assertSame(array_keys(AdminRealtimeRows::CHANNELS), AdminRealtimeAccess::allowedChannels(1));
+        DB::table('admin')->insert(['id' => 2, 'username' => 'limited']);
+        $this->app['cache']->put('admin_realtime:channels:2', ['admin.users'], 60);
+        $limited = \Firebase\JWT\JWT::encode(['iat' => time(), 'exp' => $exp, 'sub' => 2, 'user_id' => 2], str_repeat('k', 32), 'HS256');
+        foreach (['admin.stats.platform_count', 'admin.stats.unprocessed'] as $statsChannel) {
+            $this->assertSame(200, $call($token, 'private-' . $statsChannel)->getStatusCode());
+            $this->assertSame(403, $call($limited, 'private-' . $statsChannel)->getStatusCode(), '统计频道也必须校验管理员权限');
+        }
     }
 
     public function test_failed_termination_retries_without_affecting_a_newer_login(): void

+ 149 - 0
tests/Regression/admin_realtime_probe.mjs

@@ -0,0 +1,149 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { test } from 'node:test';
+
+const source = readFileSync(new URL('../../public/static/js/realtime-probe.js', import.meta.url));
+const { RealtimeProbe, ProbeError, apiAddress, apiEndpoint, redact } = await import(`data:text/javascript;base64,${source.toString('base64')}`);
+const A = 'admin.orders.withdraw';
+const B = 'admin.users.online';
+const flush = () => new Promise((resolve) => setImmediate(resolve));
+const event = (seq, channel = A) => ({ seq, id: seq, channel, event: channel === A ? 'order.updated' : 'user.online',
+  action: channel === A ? 'updated' : 'online', key: { id: 88 }, data: { status: 1 } });
+const config = { code: 0, data: { key: 'fixture-key', ws_host: 'localhost', ws_port: 8080, force_tls: false,
+  channels: [A, B, 'admin.stats.platform_count', 'admin.stats.unprocessed'], last_seq: 0, session_expires_at: 1900000000, auth_endpoint: '/admin/broadcasting/auth' } };
+
+class Subscription {
+  constructor() { this.listeners = new Map(); }
+  listen(name, callback) { this.listeners.set(name, callback); return this; }
+  subscribed(callback) { this.ready = callback; return this; }
+  error(callback) { this.failed = callback; return this; }
+  emit(message) { this.listeners.get(`.${message.event}`)?.(message); }
+}
+class Echo {
+  constructor(options) {
+    this.options = options; this.channels = new Map(); this.id = '1.1';
+    this.connector = { pusher: { connection: { bind: (name, callback) => { this[name] = callback; } } } };
+  }
+  private(name) { if (!this.channels.has(name)) this.channels.set(name, new Subscription()); return this.channels.get(name); }
+  leave(name) { this.channels.delete(name); }
+  socketId() { return this.id; }
+  disconnect() { this.state_change?.({ current: 'disconnected' }); }
+}
+const deferred = () => { let resolve; const promise = new Promise((done) => { resolve = done; }); return { resolve, promise }; };
+
+async function setup(request) {
+  const logs = [], events = [], requests = [];
+  const probe = new RealtimeProbe({ Echo, Pusher: function Pusher() {}, onLog: (entry) => logs.push(entry), onEvent: (entry) => events.push(entry),
+    request: async (url, options) => {
+      requests.push({ url, options });
+      if (request) return request(url, options);
+      if (url.includes('/config')) return structuredClone(config);
+      if (url.includes('/auth')) return { auth: 'fixture-signature' };
+      return { code: 0, data: { last_seq: 0, resync: false, events: [] } };
+    } });
+  await probe.configure('https://api.example.test/', 'Bearer fixture-token');
+  return { probe, logs, events, requests };
+}
+
+test('configuration and historical replay never count as real-time delivery', async () => {
+  const { probe } = await setup();
+  probe.connect([A]);
+  probe.echo.state_change({ current: 'connected' });
+  probe.echo.private(A).ready(); await flush();
+  probe.receive(event(1), 'replay');
+  assert.equal(probe.state.liveCount, 0);
+  assert.equal(probe.state.replayCount, 1);
+  probe.echo.private(A).emit(event(2));
+  assert.equal(probe.state.liveCount, 1);
+});
+
+test('manual reconnect retains each channel resume point and ignores a stale HTTP completion', async () => {
+  const old = deferred(); let replayCalls = 0;
+  const { probe, events, requests } = await setup((url) => {
+    if (url.includes('/config')) return structuredClone(config);
+    if (++replayCalls === 1) return old.promise;
+    return { code: 0, data: { resync: false, last_seq: 2, events: [event(2)] } };
+  });
+  probe.connect([A]); const first = probe.echo;
+  first.private(A).ready();
+  first.private(A).emit(event(1));
+  probe.disconnect();
+  old.resolve({ code: 0, data: { resync: false, last_seq: 10, events: [event(10)] } });
+  await flush();
+  probe.connect([A]); probe.echo.private(A).ready(); await flush();
+  assert.equal(new URL(requests.at(-1).url).searchParams.get('after_seq'), '0');
+  assert.deepEqual(events.map((e) => e.event.seq), [1, 2]);
+});
+
+test('changing credentials invalidates in-flight responses and clears old session data', async () => {
+  const response = deferred();
+  const { probe } = await setup();
+  probe.request = () => response.promise;
+  const loading = probe.configure('https://api.example.test', 'new-token');
+  probe.invalidate();
+  response.resolve(structuredClone(config));
+  assert.equal(await loading, null);
+  assert.equal(probe.state.config, null);
+  assert.equal(probe.token, '');
+  assert.equal(probe.channels.size, 0);
+});
+
+test('duplicate detection is scoped by seq and channel, not just seq', async () => {
+  const { probe, events } = await setup();
+  probe.receive(event(1), 'live'); probe.receive(event(1), 'replay'); probe.receive(event(1, B), 'live');
+  assert.deepEqual(events.map((e) => e.duplicate), [false, true, false]);
+  assert.equal(probe.state.duplicateCount, 1);
+});
+
+test('rejected authorization stays visible and 401 stops the expired session', async () => {
+  const { probe, logs } = await setup();
+  probe.connect([A]);
+  probe.request = async () => { throw new ProbeError('expired', 401); };
+  let callbackError;
+  await probe.authorize(`private-${A}`, '1.1', (error) => { callbackError = error; }, probe.epoch);
+  assert.equal(callbackError.status, 401);
+  assert.equal(probe.state.expired, true);
+  assert.equal(probe.echo, null);
+  assert.throws(() => probe.connect([A]), /登录已过期/);
+  assert.ok(logs.some((entry) => entry.message.includes('登录已过期')));
+});
+
+test('applying subscriptions twice does not add duplicate handlers; leaving stops that channel', async () => {
+  const { probe, events } = await setup();
+  probe.connect([A]); const sub = probe.echo.private(A);
+  probe.subscribe([A]);
+  sub.emit(event(1)); assert.equal(events.length, 1);
+  probe.subscribe([]); sub.emit(event(2)); assert.equal(events.length, 1);
+});
+
+test('redaction removes credentials from log messages and nested event payloads', () => {
+  const token = 'fixture-token';
+  const safe = redact({ message: `Echo failed for Bearer ${token}`, data: { token, password: 'hidden', text: token } }, token);
+  assert.equal(JSON.stringify(safe).includes(token), false);
+  assert.equal(safe.data.password, '[已隐藏]');
+});
+
+test('base and auth URLs cannot carry credentials or change the request origin', () => {
+  assert.throws(() => apiAddress('https://name:password@example.test'), /账号/);
+  assert.throws(() => apiAddress('javascript:alert(1)'), /http/);
+  assert.throws(() => apiEndpoint('https://api.example.test', '//other.example.test/auth'), /路径/);
+  assert.equal(apiEndpoint('https://api.example.test/base', '/admin/broadcasting/auth'), 'https://api.example.test/base/admin/broadcasting/auth');
+});
+
+test('invalid reconfiguration does not leave a previous usable configuration', async () => {
+  const { probe } = await setup();
+  await assert.rejects(probe.configure('not-an-address', 'new-token'), /完整 API 地址/);
+  assert.equal(probe.state.config, null);
+  assert.throws(() => probe.connect([A]), /先获取/);
+});
+
+test('statistics channels receive stats.updated snapshots without treating them as list rows', async () => {
+  const { probe, events } = await setup();
+  const name = 'admin.stats.platform_count';
+  probe.connect([name]);
+  probe.echo.private(name).emit({ seq: 1, channel: name, event: 'stats.updated', action: 'updated', key: { scope: 'all' },
+    data: { total: 2, apple: 1, android: 0, pc: 1, mobile: 0, wechat: 0, unknown: 0 } });
+  assert.equal(probe.state.liveCount, 1);
+  assert.equal(events[0].event.data.total, 2);
+  assert.deepEqual(events[0].event.key, { scope: 'all' });
+});

+ 93 - 0
tests/fixtures/realtime_probe_server.mjs

@@ -0,0 +1,93 @@
+// 仅供本地浏览器联调:模拟后台 API,与独立的真实 Reverb 实例通信。
+// 不读取项目 .env,不连接任何业务数据库,只监听 127.0.0.1。
+import http from 'node:http';
+import { createHash, createHmac } from 'node:crypto';
+import { readFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+
+const port = Number(process.env.PROBE_FIXTURE_PORT || 19272);
+const reverbPort = Number(process.env.PROBE_REVERB_PORT || 19273);
+const key = 'probe-fixture-key';
+const secret = 'probe-fixture-secret';
+const app = 'probe-fixture';
+const token = 'probe-test-token';
+const expiry = Math.floor(Date.now() / 1000) + 3600;
+const A = 'admin.orders.withdraw';
+const B = 'admin.users.online';
+const C = 'admin.stats.platform_count';
+const D = 'admin.stats.unprocessed';
+const CHANNELS = [A, B, C, D];
+const history = [];
+let mode = 'normal';
+const files = new Map([
+  ['/realtime-test.html', ['../../public/realtime-test.html', 'text/html']],
+  ['/static/css/realtime-test.css', ['../../public/static/css/realtime-test.css', 'text/css']],
+  ['/static/js/realtime-test.js', ['../../public/static/js/realtime-test.js', 'application/javascript']],
+  ['/static/js/realtime-probe.js', ['../../public/static/js/realtime-probe.js', 'application/javascript']],
+  ['/static/vendor/realtime/pusher-8.4.0.min.js', ['../../public/static/vendor/realtime/pusher-8.4.0.min.js', 'application/javascript']],
+  ['/static/vendor/realtime/laravel-echo-2.1.6.js', ['../../public/static/vendor/realtime/laravel-echo-2.1.6.js', 'application/javascript']],
+]);
+const json = (res, data, status = 200) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(data)); };
+const body = async (req) => {
+  let text = '';
+  for await (const chunk of req) { text += chunk; if (text.length > 65536) throw new Error('fixture request too large'); }
+  return text;
+};
+
+const server = http.createServer(async (req, res) => {
+  try {
+    const url = new URL(req.url, `http://127.0.0.1:${port}`);
+    if (req.method === 'GET' && files.has(url.pathname)) {
+      const [path, mime] = files.get(url.pathname);
+      res.writeHead(200, { 'Content-Type': `${mime}; charset=utf-8`, 'Cache-Control': 'no-store' });
+      res.end(await readFile(fileURLToPath(new URL(path, import.meta.url)))); return;
+    }
+    if (req.method === 'POST' && url.pathname === '/__fixture/mode') {
+      mode = (JSON.parse(await body(req))).mode;
+      json(res, { mode }); return;
+    }
+    if (req.method === 'POST' && url.pathname === '/__fixture/publish') {
+      const input = JSON.parse(await body(req));
+      const channel = input.channel || A;
+      if (!CHANNELS.includes(channel)) { json(res, { error: 'unknown fixture channel' }, 400); return; }
+      const statistics = channel === C || channel === D;
+      const event = { seq: history.length + 1, id: history.length + 1, channel,
+        event: statistics ? 'stats.updated' : channel === A ? 'order.updated' : 'user.online', action: statistics || channel === A ? 'updated' : 'online',
+        key: statistics ? { scope: 'all' } : { id: 88 }, data: channel === C
+          ? { total: 3, apple: 1, android: 1, pc: 1, mobile: 0, wechat: 0, unknown: 0 }
+          : channel === D ? { usdtRecharge: 1, rgRecharge: 2, rmbRecharge: 0, usdtWithdraw: 1, rgWithdraw: 0, rmbWithdraw: 0, shareClaim: 1 }
+            : { id: 88, status: 1, note: input.note || 'fixture event', token: 'private-fixture-value' },
+        source: 'browser-fixture', created_at: new Date().toISOString() };
+      const payload = JSON.stringify({ name: event.event, channels: [`private-${channel}`], data: JSON.stringify(event) });
+      const path = `/apps/${app}/events`;
+      const values = { auth_key: key, auth_timestamp: String(Math.floor(Date.now() / 1000)), auth_version: '1.0', body_md5: createHash('md5').update(payload).digest('hex') };
+      const query = new URLSearchParams(Object.entries(values).sort(([a], [b]) => a.localeCompare(b)));
+      query.set('auth_signature', createHmac('sha256', secret).update(`POST\n${path}\n${query}`).digest('hex'));
+      const reply = await fetch(`http://127.0.0.1:${reverbPort}${path}?${query}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload });
+      if (!reply.ok) throw new Error(`Reverb publish returned HTTP ${reply.status}: ${await reply.text()}`);
+      history.push(event); json(res, { seq: event.seq }); return;
+    }
+    if (req.headers.authorization !== `Bearer ${token}`) { json(res, { msg: 'fixture token invalid' }, 401); return; }
+    if (url.pathname === '/admin/realtime/config') {
+      json(res, { code: 0, data: { key, broadcaster: 'reverb', ws_host: '127.0.0.1', ws_port: reverbPort, force_tls: false,
+        channels: CHANNELS, last_seq: history.length, session_expires_at: expiry, auth_endpoint: '/admin/broadcasting/auth' } });
+    } else if (url.pathname === '/admin/broadcasting/auth' && req.method === 'POST') {
+      const data = new URLSearchParams(await body(req));
+      const channel = data.get('channel_name');
+      if (mode === 'expired') { json(res, { msg: 'fixture expired' }, 401); return; }
+      if (mode === 'denied' || !CHANNELS.map((name) => `private-${name}`).includes(channel)) { json(res, { msg: 'fixture denied' }, 403); return; }
+      const channel_data = JSON.stringify({ user_id: `1:${expiry}` });
+      const auth = `${key}:${createHmac('sha256', secret).update(`${data.get('socket_id')}:${channel}:${channel_data}`).digest('hex')}`;
+      json(res, { auth, channel_data });
+    } else if (url.pathname === '/admin/realtime/replay') {
+      const after = Number(url.searchParams.get('after_seq'));
+      const channels = (url.searchParams.get('channels') || '').split(',');
+      json(res, { code: 0, data: { resync: mode === 'resync', ...(mode === 'resync' ? { reason: 'too_many_events' } : {}),
+        last_seq: history.length, channels,
+        events: mode === 'resync' ? [] : history.filter((event) => event.seq > after && channels.includes(event.channel)) } });
+    } else json(res, { error: 'fixture route not found' }, 404);
+  } catch (error) { json(res, { error: error.message }, 500); }
+});
+server.listen(port, '127.0.0.1', () => console.log(`Probe fixture: http://127.0.0.1:${port}/realtime-test.html`));
+process.on('SIGINT', () => server.close(() => process.exit(0)));
+process.on('SIGTERM', () => server.close(() => process.exit(0)));

Некоторые файлы не были показаны из-за большого количества измененных файлов