Explorar o código

后台订单与用户列表接入 WebSocket 实时推送

- 新增事件表 admin_realtime_events:订单/用户写入在事务提交后登记(DB::afterCommit),回滚不推送,登记失败只记 realtime 日志不影响主业务
- BaseModel 钩子覆盖体彩、六合彩、足球游戏、极速、三方游戏、投注、充值、代收代付、提现、人工上下分的模型写入;User 单独注册新增用户事件
- 批量写入点单独登记:开奖结算、足球游戏批量判负、三方订单同步(仅变化订单)、USDT 充值确认、代付回调、批量锁定/设置打款信息、会员合并
- 在线状态由拉取接口定期比对在线集合产生上下线事件,覆盖两个仓库的登录与活跃
- 内部接口 /admin/realtime/events 复用各列表接口的查询把事件解析为列表单行;/admin/realtime/ticket 按列表按钮权限签发一次性连接票据
- 新增 Node 推送网关 realtime/admin-gateway.js(票据校验、频道订阅、gap 补拉、断线补发/resync、心跳、健康检查)
- USDT 提现与人民币提现列表支持 id 筛选;每小时清理过期事件
- 前端接入与部署文档 docs/admin-websocket.md;melbet_sport-api 的写入尚未接入,文档给出接入方式

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
doge hai 11 horas
pai
achega
b738a7d80f

+ 1 - 0
.gitignore

@@ -28,3 +28,4 @@ yarn-error.log
 /src/
 /doc/
 DOCKER_README.md
+/realtime/node_modules

+ 19 - 0
app/Console/Commands/PruneAdminRealtimeEvents.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\AdminRealtime\AdminRealtimeFeed;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Schema;
+
+class PruneAdminRealtimeEvents extends Command
+{
+    protected $signature = 'admin-realtime:prune';
+    protected $description = '清理超过保留时长的后台实时推送事件';
+    public function handle(): int
+    {
+        if (!Schema::hasTable('admin_realtime_events')) return self::SUCCESS;
+        $this->info('已清理后台实时推送事件:' . AdminRealtimeFeed::prune());
+        return self::SUCCESS;
+    }
+}

+ 1 - 0
app/Console/Kernel.php

@@ -39,6 +39,7 @@ class Kernel extends ConsoleKernel
         $schedule->command('share:sync-facts --write')->everyMinute()->withoutOverlapping(30);
         $schedule->command('share:settle --reconcile')->everyFiveMinutes()->withoutOverlapping(120);
         $schedule->command('share:settle')->timezone('Asia/Shanghai')->dailyAt('00:10')->withoutOverlapping(180);
+        $schedule->command('admin-realtime:prune')->hourly()->withoutOverlapping(30);
     }
 
     /**

+ 7 - 3
app/Http/Controllers/admin/PaymentOrder.php

@@ -14,6 +14,7 @@ use App\Models\Wallet;
 use App\Models\Recharge;
 use App\Models\Withdraw;
 use App\Services\BalanceLogService;
+use App\Services\AdminRealtime\AdminRealtime;
 
 class PaymentOrder extends Controller
 {
@@ -54,10 +55,12 @@ class PaymentOrder extends Controller
             // $count = PaymentOrderModel::whereIn('id', $params['ids'])->where('payment_type', $params['payment_type'])->where('status', 0)->count();
             // if ($count != count($params['ids'])) throw new Exception('数据匹配异常,请刷新页面重试!', HttpStatus::CUSTOM_ERROR);
             
-            PaymentOrderModel::whereIn('id', $params['ids'])
+            $pending = PaymentOrderModel::whereIn('id', $params['ids'])
                 ->where('payment_type', $params['payment_type'])
-                ->where('status', 0)
-                ->update(['pay_data' => $params['pay_data'], 'status' => 4, 'is_send' => 0]);
+                ->where('status', 0);
+            $ids = (clone $pending)->pluck('id')->all();
+            $pending->update(['pay_data' => $params['pay_data'], 'status' => 4, 'is_send' => 0]);
+            AdminRealtime::record('payment_orders', $ids);
 
             DB::commit();
         } catch (ValidationException $e) {
@@ -244,6 +247,7 @@ class PaymentOrder extends Controller
                 'is_locked' => ['required', 'integer', 'in:1,0'],
             ]);
             PaymentOrderModel::whereIn('id',$params['ids'])->whereIn('type',[2,4])->update(['is_locked' => $params['is_locked']]);
+            AdminRealtime::record('payment_orders', $params['ids']);
             
             DB::commit();
         } catch (ValidationException $e) {

+ 50 - 0
app/Http/Controllers/admin/Realtime.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\AdminRealtime\AdminRealtimeFeed;
+use App\Services\AdminRealtime\AdminRealtimeTicket;
+use App\Services\JwtService;
+use Exception;
+use Illuminate\Http\JsonResponse;
+
+/** 后台订单、用户列表实时推送,接入说明见 docs/admin-websocket.md */
+class Realtime extends Controller
+{
+    /** 后台登录态换取推送网关连接票据 */
+    public function ticket(JwtService $jwtService): JsonResponse
+    {
+        try {
+            if (!config('admin_realtime.enabled')) {
+                throw new Exception('实时推送未启用');
+            }
+            $token = str_replace('Bearer ', '', (string) request()->header('Authorization'));
+            $session = $jwtService->validateToken($token);
+            $result = AdminRealtimeTicket::issue((int) request()->user->id, isset($session->exp) ? (int) $session->exp : null);
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+        return $this->success($result);
+    }
+
+    /** 推送网关内部拉取事件,仅凭 X-Realtime-Secret 访问 */
+    public function events(): JsonResponse
+    {
+        $secret = (string) config('admin_realtime.internal_secret');
+        if ($secret === '' || !hash_equals($secret, (string) request()->header('X-Realtime-Secret'))) {
+            return response()->json(['code' => HttpStatus::CUSTOM_ERROR, 'timestamp' => time(), 'msg' => 'forbidden', 'data' => []], 403);
+        }
+        if (!config('admin_realtime.enabled')) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, '实时推送未启用');
+        }
+        try {
+            $gaps = array_filter(explode(',', (string) request()->input('gaps', '')), 'ctype_digit');
+            $result = AdminRealtimeFeed::pull((int) request()->input('after', -1), $gaps);
+        } catch (\Throwable $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+        return $this->success($result);
+    }
+}

+ 4 - 0
app/Http/Controllers/admin/Withdraw.php

@@ -15,6 +15,7 @@ use Exception;
 use App\Models\Withdraw as WithdrawModel;
 use App\Models\PaymentOrder;
 use App\Models\Config;
+use App\Services\AdminRealtime\AdminRealtime;
 
 class Withdraw extends Controller
 {
@@ -48,6 +49,7 @@ class Withdraw extends Controller
             $params = request()->validate([
                 'page' => ['nullable', 'integer', 'min:1'],
                 'limit' => ['nullable', 'integer', 'min:1'],
+                'id' => ['nullable', 'integer', 'min:1'],
                 'type' => ['nullable', 'integer'],
                 'member_id' => ['nullable', 'string', 'min:1'],
                 'status' => ['nullable', 'integer'],
@@ -117,6 +119,7 @@ class Withdraw extends Controller
             $params = request()->validate([
                 'page' => ['nullable', 'integer', 'min:1'],
                 'limit' => ['nullable', 'integer', 'min:1'],
+                'id' => ['nullable', 'integer', 'min:1'],
                 'member_id' => ['nullable', 'string', 'min:1'],
                 'status' => ['nullable', 'integer'],
                 'first_name' => ['nullable'],
@@ -223,6 +226,7 @@ class Withdraw extends Controller
                 'is_locked' => ['required', 'integer', 'in:1,0'],
             ]);
             WithdrawModel::whereIn('id',$params['ids'])->update(['is_locked' => $params['is_locked']]);
+            AdminRealtime::record('withdraws', $params['ids']);
 
             DB::commit();
         } catch (ValidationException $e) {

+ 2 - 0
app/Jobs/SportGameOrderJob.php

@@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Log;
 use App\Models\SportGameOrder;
 use App\Models\Wallet;
 use App\Models\FundsRecord;
+use App\Services\AdminRealtime\AdminRealtime;
 use Illuminate\Support\Facades\DB;
 
 class SportGameOrderJob implements ShouldQueue
@@ -55,6 +56,7 @@ class SportGameOrderJob implements ShouldQueue
             $ids = (clone $query)->lockForUpdate()->pluck('id')->all();
             $query->update(['status' => 1]);
             \App\Services\Reporting\ReportContext::sportGamesSettled($ids);
+            AdminRealtime::record('sport_game_order', $ids);
         });
 
         $list = SportGameOrder::where('gameplay_id', $this->gameplayId)->where('status', 0)->get();

+ 6 - 0
app/Models/BaseModel.php

@@ -30,6 +30,12 @@ class BaseModel extends Authenticatable
             try { \App\Services\Reporting\ReportContext::captureModel($model); }
             catch (\Throwable $e) { \Illuminate\Support\Facades\Log::error('reporting_context_failed', ['source' => $model->getTable(), 'id' => $model->getKey(), 'error' => get_class($e)]); }
         });
+        foreach (['created', 'updated', 'deleted'] as $action) {
+            static::registerModelEvent($action, function ($model) use ($action) {
+                try { \App\Services\AdminRealtime\AdminRealtime::captureModel($model, $action); }
+                catch (\Throwable $e) { \Illuminate\Support\Facades\Log::error('admin_realtime_capture_failed', ['source' => $model->getTable(), 'id' => $model->getKey(), 'error' => get_class($e)]); }
+            });
+        }
     }
 
     protected function getCreatedAtAttribute($value): string

+ 5 - 0
app/Models/User.php

@@ -39,6 +39,11 @@ class User extends BaseModel
                 $user->recharge_channel_group_id = 1;
             }
         });
+        // 未调用 parent::booted(),后台实时推送的新增用户事件在此单独注册
+        static::created(static function (self $user) {
+            try { \App\Services\AdminRealtime\AdminRealtime::captureModel($user, 'created'); }
+            catch (\Throwable $e) { \Illuminate\Support\Facades\Log::error('admin_realtime_capture_failed', ['source' => 'users', 'id' => $user->getKey(), 'error' => get_class($e)]); }
+        });
     }
 
     

+ 78 - 0
app/Services/AdminRealtime/AdminRealtime.php

@@ -0,0 +1,78 @@
+<?php
+
+namespace App\Services\AdminRealtime;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+/**
+ * 后台实时推送事件登记。
+ *
+ * 只登记「哪张表的哪条记录发生了什么」,事务提交后写入 admin_realtime_events;
+ * 列表行由推送网关拉取时按对应列表接口的口径解析(见 AdminRealtimeRows)。
+ * 登记失败只记 realtime 日志,绝不抛出,不影响下单、充值等主业务。
+ */
+final class AdminRealtime
+{
+    /** 监听的表 => 仅这些列变化时不推送(例如通知标记、更新时间) */
+    public const RESOURCES = [
+        'sport_order' => ['update_time'],
+        'lhc_order' => ['updated_at'],
+        'sport_game_order' => ['updated_at'],
+        'jisu_game_order' => ['updated_at'],
+        'third_game_orders' => ['updated_at'],
+        'bets' => ['updated_at'],
+        'recharges' => ['updated_at', 'is_send'],
+        'payment_orders' => ['updated_at', 'is_send'],
+        'withdraws' => ['updated_at', 'is_send'],
+        'manual_audits' => ['updated_at'],
+        'users' => [],
+    ];
+
+    /** 用户列表只推新增;在线状态由 OnlineStatusScanner 产生 */
+    public const USER_ONLINE = 'user_online';
+
+    public static function captureModel(Model $model, string $action): void
+    {
+        $table = $model->getTable();
+        if (!array_key_exists($table, self::RESOURCES)) return;
+        if ($table === 'users' && $action !== 'created') return;
+        if ($action === 'updated' && !array_diff(array_keys($model->getChanges()), self::RESOURCES[$table])) return;
+        self::record($table, $model->getKey(), $action);
+    }
+
+    /**
+     * @param string $resource 表名(不含前缀)或 user_online
+     * @param int|string|array $ids 记录主键
+     * @param string $action created / updated / deleted / online / offline
+     */
+    public static function record(string $resource, $ids, string $action = 'updated'): void
+    {
+        if (!config('admin_realtime.enabled')) return;
+        $ids = array_values(array_unique(array_filter(array_map('strval', (array) $ids), static fn(string $id): bool => $id !== '')));
+        if (!$ids) return;
+
+        $write = static function () use ($resource, $ids, $action): void {
+            try {
+                $now = now('Asia/Shanghai')->format('Y-m-d H:i:s');
+                foreach (array_chunk($ids, 500) as $chunk) {
+                    DB::table('admin_realtime_events')->insert(array_map(static fn(string $id): array => [
+                        'resource' => $resource, 'resource_id' => $id, 'action' => $action, 'source' => 'bot-28', 'created_at' => $now,
+                    ], $chunk));
+                }
+            } catch (\Throwable $e) {
+                Log::channel('realtime')->error('admin_realtime_record_failed', [
+                    'resource' => $resource, 'action' => $action, 'count' => count($ids), 'error' => $e->getMessage(),
+                ]);
+            }
+        };
+
+        // 事务内调用时等提交后再写,回滚则丢弃;不在事务内立即写
+        try {
+            DB::afterCommit($write);
+        } catch (\Throwable $e) {
+            $write();
+        }
+    }
+}

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

@@ -0,0 +1,124 @@
+<?php
+
+namespace App\Services\AdminRealtime;
+
+use App\Services\OnlineUserService;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+/**
+ * 推送网关的拉取入口:按 id 递增读取事件表并解析成列表行,顺带扫描会员上下线。
+ *
+ * 写入方可能在长事务内插入事件(melbet_sport-api),小 id 可能晚于大 id 提交,
+ * 因此网关会把跳过的 id 记为 gaps 在随后一段时间内补拉,本接口按 gaps 原样返回已出现的行。
+ */
+final class AdminRealtimeFeed
+{
+    private const ONLINE_SNAPSHOT = 'admin_realtime:online_snapshot';
+    private const ONLINE_LOCK = 'admin_realtime:online_scan';
+
+    /**
+     * @param int $after 网关已处理的最大事件 id;小于 0 表示网关刚启动,只返回当前最大 id 不回放历史
+     * @param list<int> $gaps 网关等待补拉的 id
+     * @return array{last_id:int,ids:list<int>,events:list<array>,has_more:bool}
+     */
+    public static function pull(int $after, array $gaps = []): array
+    {
+        self::scanOnline();
+        if ($after < 0) {
+            return ['last_id' => (int) DB::table('admin_realtime_events')->max('id'), 'ids' => [], 'events' => [], 'has_more' => false];
+        }
+
+        $limit = max(1, min(1000, (int) config('admin_realtime.batch_size', 200)));
+        $rows = DB::table('admin_realtime_events')->where('id', '>', $after)->orderBy('id')->limit($limit)->get();
+        $hasMore = $rows->count() >= $limit;
+        $gaps = array_values(array_filter(array_map('intval', $gaps), static fn(int $id): bool => $id > 0 && $id <= $after));
+        if ($gaps) {
+            $rows = DB::table('admin_realtime_events')->whereIn('id', array_slice($gaps, 0, 500))->orderBy('id')->get()->concat($rows);
+        }
+
+        return [
+            'last_id' => (int) ($rows->where('id', '>', $after)->max('id') ?? $after),
+            'ids' => $rows->pluck('id')->map(static fn($id): int => (int) $id)->values()->all(),
+            'events' => self::resolve($rows->all()),
+            'has_more' => $hasMore,
+        ];
+    }
+
+    /** 同一记录在一批内多次变更只解析一次,取最后一条事件的 id;先新增后更新仍按 created 推送 */
+    private static function resolve(array $rows): array
+    {
+        $groups = [];
+        foreach ($rows as $row) {
+            $key = $row->resource . ':' . $row->resource_id;
+            $created = ($groups[$key]['created'] ?? false) || $row->action === 'created';
+            $groups[$key] = ['row' => $row, 'created' => $created];
+        }
+
+        $events = [];
+        foreach ($groups as $group) {
+            $row = $group['row'];
+            $action = $row->action === 'updated' && $group['created'] ? 'created' : (string) $row->action;
+            try {
+                $messages = AdminRealtimeRows::resolve((string) $row->resource, (string) $row->resource_id, $action);
+            } catch (\Throwable $e) {
+                Log::channel('realtime')->error('admin_realtime_resolve_failed', [
+                    'event_id' => $row->id, 'resource' => $row->resource, 'resource_id' => $row->resource_id, 'error' => $e->getMessage(),
+                ]);
+                continue;
+            }
+            foreach ($messages as $message) {
+                $events[] = [
+                    'id' => (int) $row->id,
+                    'channel' => $message['channel'],
+                    'event' => self::eventName((string) $row->resource, $action),
+                    'action' => $action,
+                    'key' => $message['key'],
+                    'data' => $message['data'],
+                    'source' => (string) $row->source,
+                    'created_at' => (string) $row->created_at,
+                ];
+            }
+        }
+        usort($events, static fn(array $a, array $b): int => $a['id'] <=> $b['id']);
+
+        return $events;
+    }
+
+    private static function eventName(string $resource, string $action): string
+    {
+        if ($resource === AdminRealtime::USER_ONLINE) return 'user.' . $action;
+        return ($resource === 'users' ? 'user.' : 'order.') . $action;
+    }
+
+    /** 在线判定含「最近活跃时间窗口」,下线不一定有写入,只能定期比对在线集合 */
+    private static function scanOnline(): void
+    {
+        $interval = max(5, (int) config('admin_realtime.online_scan_seconds', 15));
+        try {
+            if (!Cache::add(self::ONLINE_LOCK, 1, $interval)) return;
+            $current = OnlineUserService::onlineUserIds();
+            $previous = Cache::get(self::ONLINE_SNAPSHOT);
+            Cache::forever(self::ONLINE_SNAPSHOT, $current);
+            if (!is_array($previous)) return;
+            AdminRealtime::record(AdminRealtime::USER_ONLINE, array_diff($current, $previous), 'online');
+            AdminRealtime::record(AdminRealtime::USER_ONLINE, array_diff($previous, $current), 'offline');
+        } catch (\Throwable $e) {
+            Log::channel('realtime')->error('admin_realtime_online_scan_failed', ['error' => $e->getMessage()]);
+        }
+    }
+
+    public static function prune(): int
+    {
+        $hours = max(1, (int) config('admin_realtime.retention_hours', 72));
+        $before = now('Asia/Shanghai')->subHours($hours)->format('Y-m-d H:i:s');
+        $deleted = 0;
+        do {
+            $batch = DB::table('admin_realtime_events')->where('created_at', '<', $before)->orderBy('id')->limit(5000)->delete();
+            $deleted += $batch;
+        } while ($batch >= 5000);
+
+        return $deleted;
+    }
+}

+ 192 - 0
app/Services/AdminRealtime/AdminRealtimeRows.php

@@ -0,0 +1,192 @@
+<?php
+
+namespace App\Services\AdminRealtime;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\admin\Bet;
+use App\Http\Controllers\admin\JisuGameOrder;
+use App\Http\Controllers\admin\LhcOrder;
+use App\Http\Controllers\admin\Order;
+use App\Http\Controllers\admin\SportGameOrder;
+use App\Http\Controllers\admin\Wallet;
+use App\Http\Controllers\admin\Withdraw;
+use App\Services\ManualAuditService;
+use App\Services\OnlineUserService;
+use App\Services\PaymentOrderService;
+use App\Services\ThirdGameOrderService;
+use App\Services\ThirdPartyDepositService;
+use App\Services\UserService;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use RuntimeException;
+
+/**
+ * 把事件表中的「表 + 主键」解析为对应后台列表接口的单行数据。
+ *
+ * 一律复用列表接口本身的查询(控制器内拼查询的直接以 id 参数调用控制器,
+ * 其余调用列表所用的 service),保证推送的行与列表接口返回的行字段一致。
+ */
+final class AdminRealtimeRows
+{
+    /** 频道 => [列表接口, 菜单按钮权限 uri, 行主键字段] */
+    public const CHANNELS = [
+        'admin.orders.sport' => ['/admin/order/list', 'admin/order/list', 'id'],
+        'admin.orders.lhc' => ['/admin/lhcOrder/list', 'admin/lhcOrder/list', 'id'],
+        'admin.orders.sport_game' => ['/admin/sportGameOrder/list', 'admin/sportGameOrder/list', 'id'],
+        'admin.orders.jisu_game' => ['/admin/jisuGameOrder/list', 'admin/jisuGameOrder/list', 'id'],
+        'admin.orders.third_game' => ['/admin/thirdGame/orders', 'admin/thirdGame/orders', 'id'],
+        'admin.orders.bet' => ['/admin/bet', 'admin/bet', 'id'],
+        'admin.orders.recharge' => ['/admin/wallet', 'admin/wallet', 'id'],
+        'admin.orders.payment' => ['/admin/paymentOrder', 'admin/paymentOrder', 'id'],
+        'admin.orders.third_party_deposit' => ['/admin/thirdPartyDeposit', 'admin/thirdPartyDeposit', 'id'],
+        'admin.orders.payment_withdraw' => ['/admin/withdraw/rmb', 'admin/withdraw/rmb', 'id'],
+        'admin.orders.withdraw' => ['/admin/withdraw', 'admin/withdraw', 'id'],
+        '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'],
+    ];
+
+    /** 表 => 默认频道(payment_orders 按 type 分流,见 paymentOrders) */
+    private const RESOURCE_CHANNELS = [
+        'sport_order' => 'admin.orders.sport',
+        'lhc_order' => 'admin.orders.lhc',
+        'sport_game_order' => 'admin.orders.sport_game',
+        'jisu_game_order' => 'admin.orders.jisu_game',
+        'third_game_orders' => 'admin.orders.third_game',
+        'bets' => 'admin.orders.bet',
+        'recharges' => 'admin.orders.recharge',
+        'withdraws' => 'admin.orders.withdraw',
+        'manual_audits' => 'admin.orders.manual_audit',
+        'users' => 'admin.users',
+        AdminRealtime::USER_ONLINE => 'admin.users.online',
+    ];
+
+    /**
+     * @return list<array{channel:string,key:array,data:?array}> 解析不到(已删除或不在列表口径内)的行不返回,deleted / offline 只带 key
+     */
+    public static function resolve(string $resource, string $id, string $action): array
+    {
+        if ($resource === 'payment_orders') {
+            return self::paymentOrders($id, $action);
+        }
+        $channel = self::RESOURCE_CHANNELS[$resource] ?? null;
+        if ($channel === null) {
+            return [];
+        }
+        if ($resource === AdminRealtime::USER_ONLINE) {
+            return self::online($id, $action);
+        }
+        if ($action === 'deleted') {
+            return [self::message($channel, ['id' => self::castId($id)], null)];
+        }
+
+        $rows = match ($resource) {
+            'sport_order' => self::viaController(Order::class, 'list', ['id' => $id]),
+            'lhc_order' => self::viaController(LhcOrder::class, 'list', ['id' => $id]),
+            'sport_game_order' => self::viaController(SportGameOrder::class, 'list', ['id' => $id]),
+            'jisu_game_order' => self::viaController(JisuGameOrder::class, 'list', ['id' => $id]),
+            'bets' => self::viaController(Bet::class, 'index', ['id' => $id]),
+            'recharges' => self::viaController(Wallet::class, 'index', ['id' => $id]),
+            'withdraws' => self::viaController(Withdraw::class, 'index', ['id' => $id]),
+            'third_game_orders' => app(ThirdGameOrderService::class)->paginate(['id' => $id])['list'] ?? [],
+            'manual_audits' => ManualAuditService::list(['id' => $id])['data'] ?? [],
+            'users' => UserService::paginate(['id' => $id])['data'] ?? [],
+        };
+        $row = self::pick($rows, 'id', $id);
+
+        return $row === null ? [] : [self::message($channel, ['id' => self::castId($id)], $row)];
+    }
+
+    /** 代收(type=1)进「代收订单」与「三方充值」两张列表,代付(type=2/4)进「人民币提现」 */
+    private static function paymentOrders(string $id, string $action): array
+    {
+        $key = ['id' => self::castId($id)];
+        if ($action === 'deleted') {
+            return array_map(static fn(string $channel): array => self::message($channel, $key, null),
+                ['admin.orders.payment', 'admin.orders.third_party_deposit', 'admin.orders.payment_withdraw']);
+        }
+        $type = (int) DB::table('payment_orders')->where('id', $id)->value('type');
+        $messages = [];
+        if ($type === PaymentOrderService::TYPE_PAY) {
+            $row = self::pick(PaymentOrderService::paginate(['type' => PaymentOrderService::TYPE_PAY, 'id' => $id])['data'] ?? [], 'id', $id);
+            if ($row !== null) $messages[] = self::message('admin.orders.payment', $key, $row);
+            $row = self::pick(ThirdPartyDepositService::list(['id' => $id])['data'] ?? [], 'id', $id);
+            if ($row !== null) $messages[] = self::message('admin.orders.third_party_deposit', $key, $row);
+        } elseif (in_array($type, [PaymentOrderService::TYPE_PAYOUT, PaymentOrderService::TYPE_SELF_PAYOUT], true)) {
+            $row = self::pick(self::viaController(Withdraw::class, 'rmb', ['id' => $id, 'type' => $type]), 'id', $id);
+            if ($row !== null) $messages[] = self::message('admin.orders.payment_withdraw', $key, $row);
+        }
+
+        return $messages;
+    }
+
+    private static function online(string $userId, string $action): array
+    {
+        $user = DB::table('users')->where('id', $userId)->first(['member_id', 'user_id']);
+        if (!$user) {
+            return [];
+        }
+        $key = ['member_id' => (string) ($user->member_id ?: $user->user_id)];
+        if ($action === 'offline') {
+            return [self::message('admin.users.online', $key, null)];
+        }
+        $row = OnlineUserService::list(['user_ids' => [(int) $userId], 'limit' => 1])['data'][0] ?? null;
+
+        return $row === null ? [] : [self::message('admin.users.online', $key, self::normalize($row))];
+    }
+
+    /**
+     * 以内部子请求调用后台列表控制器(与前端请求列表接口走同一段查询与格式化)。
+     * 列表接口必须支持 id 筛选,pick() 再按 id 校验一次,防止筛选失效时推错行。
+     */
+    private static function viaController(string $class, string $method, array $query): array
+    {
+        $app = app();
+        $original = $app->make('request');
+        $request = Request::create('/', 'GET', $query + ['page' => 1, 'limit' => 20]);
+        $request->user = $original->user ?? null;
+        $app->instance('request', $request);
+        Facade::clearResolvedInstance('request');
+        try {
+            $response = $app->call([$app->make($class), $method]);
+        } finally {
+            $app->instance('request', $original);
+            Facade::clearResolvedInstance('request');
+        }
+        $body = $response instanceof JsonResponse ? $response->getData(true) : [];
+        if ((int) ($body['code'] ?? -1) !== HttpStatus::OK) {
+            throw new RuntimeException($class . '@' . $method . ' failed: ' . ($body['msg'] ?? 'non-json response'));
+        }
+
+        return $body['data']['data'] ?? [];
+    }
+
+    private static function pick($rows, string $field, string $id): ?array
+    {
+        foreach (self::normalize($rows) as $row) {
+            if (is_array($row) && (string) ($row[$field] ?? '') === $id) {
+                return $row;
+            }
+        }
+
+        return null;
+    }
+
+    /** 与 HTTP 响应相同的序列化(模型的 appends / hidden / casts 均生效) */
+    private static function normalize($value): array
+    {
+        return json_decode(json_encode($value), true) ?: [];
+    }
+
+    private static function message(string $channel, array $key, ?array $data): array
+    {
+        return ['channel' => $channel, 'key' => $key, 'data' => $data];
+    }
+
+    private static function castId(string $id)
+    {
+        return ctype_digit($id) ? (int) $id : $id;
+    }
+}

+ 67 - 0
app/Services/AdminRealtime/AdminRealtimeTicket.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace App\Services\AdminRealtime;
+
+use App\Services\MenuService;
+use App\Services\RoleService;
+use App\Services\RoleUserService;
+use RuntimeException;
+
+/**
+ * 推送网关连接票据:后台登录态(admin.jwt)换取短时效票据,网关用同一密钥校验签名。
+ * 票据内携带该管理员可订阅的频道,口径与 CheckButtonPermission 对列表接口的按钮权限一致。
+ */
+final class AdminRealtimeTicket
+{
+    /**
+     * @param int|null $sessionExpiresAt 后台 JWT 的过期时间戳,网关到期主动断开
+     */
+    public static function issue(int $adminId, ?int $sessionExpiresAt): array
+    {
+        $secret = (string) config('admin_realtime.ticket_secret');
+        if ($secret === '') {
+            throw new RuntimeException('未配置 ADMIN_WS_SECRET');
+        }
+        $channels = self::allowedChannels($adminId);
+        $ttl = max(10, (int) config('admin_realtime.ticket_ttl', 60));
+        $now = time();
+        $body = self::base64Url(json_encode([
+            'aid' => $adminId, 'ch' => $channels, 'iat' => $now, 'exp' => $now + $ttl,
+            'sxp' => $sessionExpiresAt, 'nonce' => bin2hex(random_bytes(8)),
+        ]));
+
+        return [
+            'ticket' => $body . '.' . self::base64Url(hash_hmac('sha256', $body, $secret, true)),
+            'expires_in' => $ttl,
+            'url' => (string) config('admin_realtime.public_url'),
+            'channels' => $channels,
+        ];
+    }
+
+    /** @return list<string> */
+    public static function allowedChannels(int $adminId): array
+    {
+        $channels = array_keys(AdminRealtimeRows::CHANNELS);
+        if ($adminId === 1) {
+            return $channels;
+        }
+        $roleIds = collect(RoleUserService::findAll(['user_id' => $adminId]))->pluck('role_id')->toArray() ?: [-1];
+        $granted = [];
+        foreach (RoleService::model()::with(['menus' => function ($query) {
+            $query->where('type', MenuService::model()::TYPE_BUTTON);
+        }])->whereIn('id', $roleIds)->get() as $role) {
+            $granted = array_merge($granted, $role['menus_uris'] ?: []);
+        }
+
+        return array_values(array_filter($channels, static function (string $channel) use ($granted): bool {
+            $uri = AdminRealtimeRows::CHANNELS[$channel][1];
+            $menu = MenuService::findOne(['uri' => $uri, 'type' => MenuService::model()::TYPE_BUTTON, 'status' => MenuService::model()::STATUS_SHOW]);
+            return empty($menu) || in_array($uri, $granted, true);
+        }));
+    }
+
+    private static function base64Url(string $value): string
+    {
+        return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
+    }
+}

+ 3 - 0
app/Services/BetService.php

@@ -23,6 +23,7 @@ use Telegram\Bot\Api;
 use Telegram\Bot\Exceptions\TelegramSDKException;
 use App\Services\ActivityRewardService;
 use App\Services\Sharing\EffectiveBet;
+use App\Services\AdminRealtime\AdminRealtime;
 
 /**
  * 投注
@@ -791,6 +792,7 @@ class BetService extends BaseService
             //下注项状态更新为未中奖
             LhcOrderItem::where('ordernum', $v['ordernum'])->update(['lottery_status' => self::model()::STATUS_LOSS]);
             \App\Services\Reporting\ReportContext::lotterySettled((int) $v['id']);
+            AdminRealtime::record('lhc_order', $v['id']);
         }
 
     }
@@ -957,6 +959,7 @@ class BetService extends BaseService
                 LhcOrder::where("id", $v['id'])->update(['lottery_status' => '1', 'win_amount' => 0]);
             }
             \App\Services\Reporting\ReportContext::lotterySettled((int) $v['id']);
+            AdminRealtime::record('lhc_order', $v['id']);
         }
         $openList = array_merge($openList, $fakeOpenData['list']);
 

+ 20 - 0
app/Services/OnlineUserService.php

@@ -58,6 +58,12 @@ class OnlineUserService
             $where[] = 'u.first_name LIKE ?';
             $bindings[] = '%' . $params['first_name'] . '%';
         }
+        // 内部参数:后台实时推送按 users.id 取单行
+        if (!empty($params['user_ids'])) {
+            $ids = array_values(array_unique(array_map('intval', (array)$params['user_ids'])));
+            $where[] = 'u.id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')';
+            array_push($bindings, ...$ids);
+        }
 
         $statusCond = $hasLoginStatus ? 'AND status = ' . (int)UserLogin::STATUS_SUCCESS : '';
         if (!empty($params['login_ip']) || (!empty($params['platform']) && $params['platform'] !== '全部' && $hasPlatform)) {
@@ -163,6 +169,20 @@ class OnlineUserService
         return ['total' => $total, 'data' => $data];
     }
 
+    /**
+     * 当前在线用户的 users.id(与 list 相同在线判定),供后台实时推送比对上下线
+     *
+     * @return list<int>
+     */
+    public static function onlineUserIds(): array
+    {
+        $users = DB::getTablePrefix() . 'users';
+        [$onlineSql, $bindings] = self::onlinePredicateSql(DB::getTablePrefix() . 'user_session', 'u');
+        $rows = DB::select("SELECT u.id FROM {$users} u WHERE {$onlineSql} AND (u.`from` IS NULL OR u.`from` <> 2)", $bindings);
+
+        return array_map(static fn($row): int => (int)$row->id, $rows);
+    }
+
     /**
      * 当前在线用户按端计数(与 list 相同在线判定)
      *

+ 5 - 0
app/Services/PaymentOrderService.php

@@ -17,6 +17,7 @@ use App\Services\Payment\SanJinService;
 use App\Services\Payment\ZimuPayService;
 use App\Services\Payment\PaymentProviderService;
 use App\Services\ConfigService;
+use App\Services\AdminRealtime\AdminRealtime;
 
 /**
  * 投注
@@ -1295,6 +1296,7 @@ class PaymentOrderService extends BaseService
             if ((string)$params['status'] === JdPayService::REMIT_STATUS_SUCCESS) {
                 $data['status'] = self::STATUS_SUCCESS;
                 $res = static::$MODEL::where(['id' => $info->id])->update($data);
+                AdminRealtime::record('payment_orders', $info->id);
                 if ($res) {
                     $text = "✅ 提现通知 \n";
                     $text .= "提现平台:{$info->bank_name} \n";
@@ -1307,6 +1309,7 @@ class PaymentOrderService extends BaseService
             } elseif ((string)$params['status'] === JdPayService::REMIT_STATUS_FAIL) {
                 $data['status'] = self::STATUS_FAIL;
                 $res = static::$MODEL::where(['id' => $info->id])->update($data);
+                AdminRealtime::record('payment_orders', $info->id);
 
                 $wallet = WalletService::findOne(['member_id' => $info->member_id]);
                 $balance = $wallet->available_balance;
@@ -1449,6 +1452,7 @@ class PaymentOrderService extends BaseService
             if ($params['state'] == 1) {
                 $data['status'] = self::STATUS_SUCCESS;
                 $res = static::$MODEL::where(['order_no' => $params['orderNo']])->update($data);
+                AdminRealtime::record('payment_orders', $info->id);
                 if ($res) {
                     $text = "✅ 提现通知 \n";
                     $text .= "提现平台:{$info->bank_name} \n";
@@ -1461,6 +1465,7 @@ class PaymentOrderService extends BaseService
             } else {
                 $data['status'] = self::STATUS_FAIL;
                 $res = static::$MODEL::where(['order_no' => $params['orderNo']])->update($data);
+                AdminRealtime::record('payment_orders', $info->id);
 
                 $wallet = WalletService::findOne(['member_id' => $info->member_id]);
 

+ 9 - 1
app/Services/RechargeBonusService.php

@@ -5,6 +5,7 @@ namespace App\Services;
 use App\Models\BalanceLog;
 use App\Models\User;
 use App\Models\Wallet;
+use App\Services\AdminRealtime\AdminRealtime;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
@@ -45,7 +46,13 @@ final class RechargeBonusService
 
     public static function registerChain(array $row): int
     {
-        if (!self::available()) return DB::table('recharges')->insertOrIgnore($row);
+        if (!self::available()) {
+            $inserted = DB::table('recharges')->insertOrIgnore($row);
+            if ($inserted && !empty($row['txid']) && !empty($row['net'])) {
+                AdminRealtime::record('recharges', self::chainRows($row['net'], $row['txid'])->orderByDesc('id')->value('id'), 'created');
+            }
+            return $inserted;
+        }
         if (empty($row['txid']) || empty($row['net'])) return 0;
         return DB::transaction(function () use ($row) {
             $key = self::chainKey($row['net'], $row['txid']); $now = self::now();
@@ -61,6 +68,7 @@ final class RechargeBonusService
             $row['recharge_bonus_snapshot'] = $snapshot === null ? null : json_encode($snapshot, JSON_THROW_ON_ERROR);
             $row['created_at'] = $row['updated_at'] = $now;
             $id = DB::table('recharges')->insertGetId($row);
+            AdminRealtime::record('recharges', $id, 'created');
             DB::table('recharge_bonus_chain_receipts')->where('dedup_key', $key)->update(['recharge_id' => $id]);
             return 1;
         }, 3);

+ 5 - 0
app/Services/RechargeService.php

@@ -17,6 +17,7 @@ use App\Services\UserService;
 use App\Services\BalanceLogService;
 use App\Helpers\TronHelper;
 use App\Models\Config;
+use App\Services\AdminRealtime\AdminRealtime;
 
 /**
  * 用户充值记录
@@ -502,7 +503,9 @@ class RechargeService extends BaseService
                         TopUpService::notifyTransferSuccess($info->member_id, $text);
                     }
                 } else {
+                    $ids = static::$MODEL::where(self::getWhere(['txid' => $txid, 'status' => static::$MODEL::STATUS_STAY]))->pluck('id')->all();
                     static::$MODEL::where(self::getWhere(['txid' => $txid, 'status' => static::$MODEL::STATUS_STAY]))->update($data);
+                    AdminRealtime::record('recharges', $ids);
                 }
             }
         }
@@ -522,6 +525,7 @@ class RechargeService extends BaseService
             if (!$wallet) throw new \RuntimeException('充值钱包不存在');
             $updated = static::$MODEL::where('id', $info->id)->where('status', Recharge::STATUS_STAY)->update($confirmed);
             if (!$updated) return false;
+            AdminRealtime::record('recharges', $info->id);
             $before = (string)$wallet->available_balance;
             $wallet->available_balance = bcadd($before, $amount, 10); $wallet->save();
             $principal = BalanceLogService::addLog($info->member_id, $amount, $before, $wallet->available_balance, '充值', $info->id, '');
@@ -543,6 +547,7 @@ class RechargeService extends BaseService
         ]))->update([
             'status' => static::$MODEL::STATUS_IGNORE,
         ]);
+        AdminRealtime::record('recharges', $info->id);
 
         Log::warning('忽略无效USDT自动充值记录', array_merge([
             'id' => $info->id,

+ 5 - 0
app/Services/SecretService.php

@@ -11,6 +11,7 @@ use App\Models\PaymentOrder;
 use App\Models\Rebate;
 use App\Models\User;
 use App\Models\Wallet;
+use App\Services\AdminRealtime\AdminRealtime;
 use Illuminate\Support\Facades\Cache;
 use Telegram\Bot\Api;
 use Telegram\Bot\Exceptions\TelegramSDKException;
@@ -134,8 +135,12 @@ class SecretService
 
         Address::where('member_id', $oldMemberId)->update(['member_id' => $newMemberId]);
         Bank::where('member_id', $oldMemberId)->update(['member_id' => $newMemberId]);
+        $betIds = Bet::where('member_id', $oldMemberId)->pluck('id')->all();
+        $paymentOrderIds = PaymentOrder::where('member_id', $oldMemberId)->pluck('id')->all();
         Bet::where('member_id', $oldMemberId)->update(['member_id' => $newMemberId, 'user_id' => $newUserId]);
         PaymentOrder::where('member_id', $oldMemberId)->update(['member_id' => $newMemberId]);
+        AdminRealtime::record('bets', $betIds);
+        AdminRealtime::record('payment_orders', $paymentOrderIds);
 
         $wallet = Wallet::where('member_id', $user->getMemberId())->first();
         if ($wallet->available_balance > 0) {

+ 43 - 1
app/Services/ThirdGameOrderService.php

@@ -3,6 +3,7 @@
 namespace App\Services;
 
 use App\Models\ThirdGameOrder as ThirdGameOrderModel;
+use App\Services\AdminRealtime\AdminRealtime;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
 
@@ -114,6 +115,8 @@ class ThirdGameOrderService
         }
 
         if ($rows !== []) {
+            $changedKeys = array_keys(array_filter($rows, fn(array $row, string $key): bool
+                => $this->snapshotChanged($existing[$key] ?? null, $row), ARRAY_FILTER_USE_BOTH));
             ThirdGameOrderModel::query()->upsert(
                 array_values($rows),
                 ['order_key'],
@@ -124,6 +127,7 @@ class ThirdGameOrderService
                     'last_update_time', 'updated_at',
                 ]
             );
+            $this->recordRealtime($changedKeys, $existing);
         }
 
         $pageNo = (int) ($result['page_no'] ?? $page);
@@ -215,6 +219,10 @@ class ThirdGameOrderService
                     });
             });
         }
+        // 内部参数:后台实时推送按主键取单行
+        if (!empty($params['id'])) {
+            $query->where('third_game_orders.id', (int) $params['id']);
+        }
         if (!empty($params['player_id'])) {
             $query->where('third_game_orders.player_id', (string) $params['player_id']);
         }
@@ -345,12 +353,14 @@ class ThirdGameOrderService
             ->distinct()->pluck('player_id')->all();
         foreach (array_chunk($playerIds, 200) as $chunk) {
             foreach ($this->usersByLegacyPlayerId($chunk) as $playerId => $user) {
+                $ids = ThirdGameOrderModel::query()->whereNull('user_id')->where('player_id', $playerId)->pluck('id')->all();
                 $updated += ThirdGameOrderModel::query()->whereNull('user_id')->where('player_id', $playerId)->update([
                     'user_id' => $user->id,
                     'member_id' => $user->member_id,
                     'username' => (string) ($user->username ?? ''),
                     'first_name' => (string) ($user->first_name ?? ''),
                 ]);
+                AdminRealtime::record('third_game_orders', $ids);
             }
         }
 
@@ -360,6 +370,38 @@ class ThirdGameOrderService
     /**
      * @return array<string, ThirdGameOrderModel>
      */
+    /** 同步每分钟全量 upsert,只有新订单或状态、结算、会员归属变化才推送后台 */
+    private function snapshotChanged($previous, array $row): bool
+    {
+        if ($previous === null) {
+            return true;
+        }
+        $settled = $previous->getRawOriginal('settled_amount');
+        $settledChanged = is_numeric($settled) && is_numeric($row['settled_amount'])
+            ? bccomp((string) $settled, (string) $row['settled_amount'], 10) !== 0
+            : $settled !== $row['settled_amount'];
+
+        return $settledChanged
+            || (int) $previous->getRawOriginal('status') !== (int) $row['status']
+            || (string) $previous->getRawOriginal('last_update_time') !== (string) $row['last_update_time']
+            || (string) $previous->getRawOriginal('user_id') !== (string) $row['user_id'];
+    }
+
+    private function recordRealtime(array $changedKeys, array $existing): void
+    {
+        if ($changedKeys === [] || !config('admin_realtime.enabled')) {
+            return;
+        }
+        try {
+            $ids = ThirdGameOrderModel::query()->whereIn('order_key', $changedKeys)->pluck('id', 'order_key')->all();
+            $created = array_values(array_intersect_key($ids, array_diff_key(array_flip($changedKeys), $existing)));
+            AdminRealtime::record('third_game_orders', $created, 'created');
+            AdminRealtime::record('third_game_orders', array_values(array_diff($ids, $created)));
+        } catch (\Throwable $e) {
+            \Illuminate\Support\Facades\Log::channel('realtime')->error('admin_realtime_third_game_failed', ['error' => $e->getMessage()]);
+        }
+    }
+
     private function existingSnapshots(array $orders): array
     {
         $orderKeys = [];
@@ -381,7 +423,7 @@ class ThirdGameOrderService
 
         $rows = ThirdGameOrderModel::query()
             ->whereIn('order_key', $orderKeys)
-            ->get(['order_key', 'user_id', 'member_id', 'username', 'first_name', 'created_at']);
+            ->get(['id', 'order_key', 'user_id', 'member_id', 'username', 'first_name', 'status', 'settled_amount', 'last_update_time', 'created_at']);
         $result = [];
         foreach ($rows as $row) {
             $result[(string) $row->order_key] = $row;

+ 30 - 0
config/admin_realtime.php

@@ -0,0 +1,30 @@
+<?php
+
+/*
+| 后台实时推送(订单 / 用户列表)。完整说明见 docs/admin-websocket.md。
+| 业务写入在事务提交后登记到 admin_realtime_events,推送网关(realtime/admin-gateway.js)
+| 轮询内部接口取出事件并推给已登录的后台管理员。
+*/
+return [
+    // 关闭时不登记任何事件,未执行迁移或未部署网关时保持关闭
+    'enabled' => (bool) env('ADMIN_WS_ENABLED', false),
+
+    // 前端连接地址,随票据接口返回,例如 wss://admin.example.com/admin-ws
+    'public_url' => env('ADMIN_WS_PUBLIC_URL', ''),
+
+    // 票据签名密钥(与网关共用),未配置时退回 JWT_SECRET
+    'ticket_secret' => env('ADMIN_WS_SECRET') ?: env('JWT_SECRET', ''),
+    'ticket_ttl' => (int) env('ADMIN_WS_TICKET_TTL', 60),
+
+    // 网关调用内部拉取接口时携带的密钥(Header: X-Realtime-Secret),未配置时内部接口拒绝访问
+    'internal_secret' => env('ADMIN_WS_INTERNAL_SECRET', ''),
+
+    // 单次拉取最多处理的事件数
+    'batch_size' => (int) env('ADMIN_WS_BATCH_SIZE', 200),
+
+    // 在线状态扫描间隔(秒),由拉取接口顺带执行
+    'online_scan_seconds' => (int) env('ADMIN_WS_ONLINE_SCAN_SECONDS', 15),
+
+    // 事件表保留时长(小时)
+    'retention_hours' => (int) env('ADMIN_WS_RETENTION_HOURS', 72),
+];

+ 8 - 0
config/logging.php

@@ -90,6 +90,14 @@ return [
             'permission' => 0664,
         ],
 
+        'realtime' => [
+            'driver' => 'daily',
+            'path' => storage_path('logs/realtime/admin.log'),
+            'level' => env('LOG_LEVEL', 'debug'),
+            'days' => 14,
+            'permission' => 0664,
+        ],
+
         'slack' => [
             'driver' => 'slack',
             'url' => env('LOG_SLACK_WEBHOOK_URL'),

+ 20 - 0
database/migrations/2026_10_31_160000_create_admin_realtime_events.php

@@ -0,0 +1,20 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+/** 后台实时推送事件表:bot-28 与 melbet_sport-api 均可写入,推送网关按 id 递增消费。 */
+return new class extends Migration {
+    public function up(): void
+    {
+        Schema::create('admin_realtime_events', function (Blueprint $t) {
+            $t->id(); $t->string('resource', 32); $t->string('resource_id', 64);
+            $t->string('action', 16); $t->string('source', 32)->default('bot-28');
+            $t->dateTime('created_at');
+            $t->index('created_at', 'admin_realtime_created');
+        });
+    }
+
+    public function down(): void { Schema::dropIfExists('admin_realtime_events'); }
+};

+ 517 - 0
docs/admin-websocket.md

@@ -0,0 +1,517 @@
+# 后台订单 / 用户列表 WebSocket 实时推送
+
+后台各订单列表、用户列表、在线用户列表通过 WebSocket 接收「新增 / 变更 / 删除 / 上下线」事件。事件里带的 `data` 就是对应列表接口返回的那一行,前端可以直接插入或替换表格行,不用再请求一次列表。
+
+## 1. 架构与选型
+
+```
+业务写入(下单、支付回调、结算、审核、注册……)
+   │ 事务提交后登记一条「表 + 主键 + 动作」
+   ▼
+bot_admin_realtime_events(事件表,bot-28 与 melbet_sport-api 都能写)
+   ▲ 每秒拉取 GET /admin/realtime/events(内部密钥)
+   │ Laravel 用列表接口自己的查询把记录解析成列表行
+realtime/admin-gateway.js(Node 推送网关,单进程常驻)
+   │ wss://<后台域名>/admin-ws?ticket=…
+   ▼
+后台前端(只推送给已登录、且有对应列表按钮权限的管理员)
+```
+
+选型理由:
+
+- 项目原来没有广播基础设施(`BROADCAST_DRIVER` 为 `null/log`,没有 pusher / reverb / laravel-websockets 依赖)。会员端已有的 Swoole WebSocket(melbet_sport-api `websocket:start`)采用「业务事务内写事件表、常驻进程轮询推送」,本方案沿用同一思路,不引入 Redis 发布订阅或第三方服务。
+- 事件表放在共享业务库里,**melbet_sport-api 不需要任何新依赖**就能写入同一张表,前台产生的订单也能推到后台(见第 7 节)。
+- Laravel 9 不能用 Reverb,laravel-websockets 已停止维护;后台推送连接数少,一个依赖 `ws` 的 Node 小进程就够了。服务器已有 Node(`node/` 目录的 TRON 转账脚本在用)。
+- 推送的行由 Laravel 解析:直接以 `id` 参数调用各列表接口自己的查询与格式化,所以推送行和列表行一致,以后列表改了字段,推送跟着变。
+- 事件登记放在事务提交之后(`DB::afterCommit`),事务回滚就不推送;登记、解析、推送任何一步失败都只记 `storage/logs/realtime/` 日志,不影响下单、充值等主业务。
+
+## 2. 接入流程
+
+1. 登录后台后调用 `GET /admin/realtime/ticket` 换取一次性连接票据(有效期 60 秒,只能使用一次)。
+2. 连接 `data.url + '?ticket=' + data.ticket`。
+3. 收到 `welcome` 后发送 `subscribe`,订阅当前页面需要的频道。
+4. 处理 `event` 消息:按 `channel` 找到列表,按 `action` 插入、替换或删除行。
+5. 断线后重新换票重连,订阅时带上收到过的最大事件 `id`(`last_event_id`);收到 `resync` 时重新请求一次列表。
+
+### 2.1 换取票据
+
+`GET /admin/realtime/ticket`,请求头 `Authorization: Bearer <后台 token>`,只要求后台登录态,不需要额外菜单权限。
+
+```json
+{
+  "code": 0,
+  "timestamp": 1793444400,
+  "msg": "OK",
+  "data": {
+    "ticket": "eyJhaWQiOjEsImNoIjpbImFkbWluLm9yZGVycy5zcG9ydCJdLC4uLn0.Q2xx…",
+    "expires_in": 60,
+    "url": "wss://admin.example.com/admin-ws",
+    "channels": ["admin.orders.sport", "admin.orders.withdraw", "admin.users", "admin.users.online"]
+  }
+}
+```
+
+| 字段 | 说明 |
+| --- | --- |
+| `ticket` | 一次性票据,拼在连接地址 `?ticket=` 后面;每次连接(包括重连)都要重新换取 |
+| `expires_in` | 票据有效秒数,默认 60 |
+| `url` | 网关连接地址(服务端 `ADMIN_WS_PUBLIC_URL`);为空时前端用自己的默认值 |
+| `channels` | 当前管理员可订阅的频道,按各列表接口的按钮权限过滤(与 `check.button.uri` 口径一致,超级管理员 `id=1` 拥有全部频道) |
+
+未启用推送(`ADMIN_WS_ENABLED=false`)时返回 `code != 0`、`msg` 含「实时推送未启用」,前端退回为只用 HTTP 列表,不需要重试。
+
+### 2.2 连接与关闭码
+
+```
+wss://admin.example.com/admin-ws?ticket=<ticket>
+```
+
+| 关闭码 | 含义 | 前端处理 |
+| --- | --- | --- |
+| `4401` reason `invalid ticket` | 票据无效、过期或已被使用 | 重新换票后重连 |
+| `4401` reason `session expired` | 后台登录 token 已过期,网关主动断开 | 走后台的登录失效流程,不要自动重连 |
+| `1012` | 网关重启 | 退避后换票重连 |
+| `1006` 等其它 | 网络断开 | 退避(1s、2s、5s、10s,最多 30s)后换票重连 |
+
+网关每 25 秒发送 WebSocket ping,浏览器会自动回 pong,前端不用处理。需要应用层心跳时可以发 `{"type":"ping"}`。
+
+### 2.3 消息格式
+
+前端发送:
+
+| 消息 | 说明 |
+| --- | --- |
+| `{"type":"subscribe","channels":["admin.orders.sport"],"last_event_id":1532}` | 订阅频道;`last_event_id` 可选,填写后会补发断线期间该频道的事件 |
+| `{"type":"unsubscribe","channels":["admin.orders.sport"]}` | 取消订阅(离开页面时调用) |
+| `{"type":"ping"}` | 应用层心跳,回 `pong` |
+
+网关发送:
+
+| `type` | 示例 | 说明 |
+| --- | --- | --- |
+| `welcome` | `{"type":"welcome","admin_id":1,"channels":[…],"last_event_id":1532,"server_time":1793444400}` | 连接成功。`last_event_id` 是网关已经处理到的事件 id |
+| `subscribed` | `{"type":"subscribed","channels":["admin.orders.sport"],"denied":["admin.orders.withdraw"],"last_event_id":1532}` | `denied` 是没有权限的频道,已被忽略 |
+| `unsubscribed` | `{"type":"unsubscribed","channels":[…]}` | |
+| `event` | 见第 4 节 | 列表数据变化 |
+| `resync` | `{"type":"resync","channels":["admin.orders.sport"],"reason":"history_unavailable"}` | 断线太久,网关不再保留期间的事件,需重新请求这些频道的列表 |
+| `pong` | `{"type":"pong","server_time":1793444400}` | |
+| `error` | `{"type":"error","message":"invalid json"}` | 前端发了无法识别的消息 |
+
+## 3. 频道与列表页
+
+| 频道 | 对应列表接口(页面) | 行主键 `key` | 可能的 `event` |
+| --- | --- | --- | --- |
+| `admin.orders.sport` | `GET /admin/order/list`(体彩订单) | `id` | `order.created` `order.updated` `order.deleted` |
+| `admin.orders.lhc` | `GET /admin/lhcOrder/list`(六合彩 / 彩票订单) | `id` | 同上 |
+| `admin.orders.sport_game` | `GET /admin/sportGameOrder/list`(足球游戏订单) | `id` | 同上 |
+| `admin.orders.jisu_game` | `GET /admin/jisuGameOrder/list`(极速游戏订单) | `id` | 同上 |
+| `admin.orders.third_game` | `GET /admin/thirdGame/orders`(三方游戏订单) | `id` | 同上 |
+| `admin.orders.bet` | `GET /admin/bet`(投注记录) | `id` | 同上 |
+| `admin.orders.recharge` | `GET /admin/wallet`(USDT 充值记录) | `id` | 同上 |
+| `admin.orders.payment` | `GET /admin/paymentOrder`(代收订单,`type=1`) | `id` | 同上 |
+| `admin.orders.third_party_deposit` | `GET /admin/thirdPartyDeposit`(三方充值,`type=1`) | `id` | 同上 |
+| `admin.orders.payment_withdraw` | `GET /admin/withdraw/rmb`(人民币提现 / 代付,`type=2/4`) | `id` | 同上 |
+| `admin.orders.withdraw` | `GET /admin/withdraw`(USDT 提现) | `id` | 同上 |
+| `admin.orders.manual_audit` | `GET /admin/manualAudit`(人工上下分) | `id` | 同上 |
+| `admin.users` | `GET /admin/user`(用户列表) | `id`(users.id) | `user.created` |
+| `admin.users.online` | `GET /admin/online/users`(在线用户) | `member_id` | `user.online` `user.offline` |
+
+同一笔代收订单会同时推到 `admin.orders.payment` 和 `admin.orders.third_party_deposit` 两个频道(两张列表都显示它),两条消息的 `id` 相同、`channel` 不同。
+
+`GET /admin/withdraw` 与 `GET /admin/withdraw/rmb` 新增了可选查询参数 `id`(精确匹配),收到事件后想单独刷新一行也可以用它;其余列表原本就支持 `id`(在线用户用 `member_id`)。
+
+## 4. 事件载荷
+
+通用结构:
+
+```json
+{
+  "type": "event",
+  "id": 1533,
+  "channel": "admin.orders.withdraw",
+  "event": "order.updated",
+  "action": "updated",
+  "key": { "id": 88 },
+  "data": { "...": "与对应列表接口 data.data[] / data.list[] 的单行完全相同" },
+  "source": "bot-28",
+  "created_at": "2026-10-31 10:00:00"
+}
+```
+
+| 字段 | 说明 |
+| --- | --- |
+| `id` | 事件 id,全局递增;用于去重和断线补发(`last_event_id`)。少数情况下较小的 id 会晚到(写入方事务提交较晚),不要据此丢弃事件 |
+| `channel` | 频道,决定更新哪张列表 |
+| `event` | `order.created` / `order.updated` / `order.deleted` / `user.created` / `user.online` / `user.offline` |
+| `action` | `created` / `updated` / `deleted` / `online` / `offline`,即 `event` 的后半段 |
+| `key` | 行主键,用来在表格中定位行:订单与用户为 `{"id": …}`,在线用户为 `{"member_id": "…"}` |
+| `data` | 列表行。`deleted`、`offline` 时为 `null` |
+| `source` | 事件来源:`bot-28`,或接入后的 `melbet_sport-api` |
+| `created_at` | 事件登记时间(Asia/Shanghai) |
+
+`data` 在推送时按数据库的最新状态解析,并不是变更瞬间的快照。同一记录在一秒内多次变化只推一次;新增后马上又更新时仍按 `created` 推送。若记录不在列表接口的口径内(例如被列表本身的联表或筛选条件排除),不推送。
+
+### 4.1 示例:USDT 提现 `admin.orders.withdraw`
+
+`data` 字段与 `GET /admin/withdraw` 的 `data.data[]` 相同:
+
+```json
+{
+  "type": "event",
+  "id": 1533,
+  "channel": "admin.orders.withdraw",
+  "event": "order.created",
+  "action": "created",
+  "key": { "id": 1 },
+  "data": {
+    "id": 1,
+    "member_id": "9001",
+    "amount": 12.5,
+    "service_charge": 0,
+    "to_account": 12.5,
+    "to_account_usdt": 12.5,
+    "address": "TQ…",
+    "exchange_rate": 7.3,
+    "status": 0,
+    "after_balance": 0,
+    "remark": null,
+    "admin_note": null,
+    "is_locked": 0,
+    "is_send": 0,
+    "created_at": "2026-10-31 10:00:00",
+    "updated_at": "2026-10-31 10:00:00",
+    "first_name": "Alice",
+    "user_admin_note": "vip"
+  },
+  "source": "bot-28",
+  "created_at": "2026-10-31 10:00:00"
+}
+```
+
+### 4.2 示例:在线用户 `admin.users.online`
+
+上线,`data` 与 `GET /admin/online/users` 的 `data.data[]` 相同:
+
+```json
+{
+  "type": "event",
+  "id": 1534,
+  "channel": "admin.users.online",
+  "event": "user.online",
+  "action": "online",
+  "key": { "member_id": "9001" },
+  "data": {
+    "member_id": "9001",
+    "first_name": "Alice",
+    "available_balance": 120.5,
+    "login_ip": "1.2.3.4",
+    "location": "CN",
+    "login_domain": "m.example.com",
+    "platform": "H5",
+    "browser": "Chrome",
+    "os": "iOS",
+    "login_time": "2026-10-31 09:59:12",
+    "last_active_time": "2026-10-31 09:59:40"
+  },
+  "source": "bot-28",
+  "created_at": "2026-10-31 10:00:00"
+}
+```
+
+下线:
+
+```json
+{ "type": "event", "id": 1590, "channel": "admin.users.online", "event": "user.offline", "action": "offline",
+  "key": { "member_id": "9001" }, "data": null, "source": "bot-28", "created_at": "2026-10-31 10:31:00" }
+```
+
+在线判定与在线列表一致(存在未过期 `user_session`,或 30 分钟内活跃,不含虚拟用户)。网关每 `ADMIN_WS_ONLINE_SCAN_SECONDS`(默认 15 秒)比对一次在线集合,所以上下线最多延迟一个扫描周期;「30 分钟未活跃自动下线」这类没有写入动作的变化也会推送。按端统计(`GET /admin/online/platformCount`)没有单独推送,收到上下线事件后可以节流(例如 10 秒一次)重新请求。
+
+### 4.3 其它频道的 `data`
+
+| 频道 | `data` 来源 | 主要字段 |
+| --- | --- | --- |
+| `admin.orders.sport` | `/admin/order/list` 单行 | `sport_order` 全部字段,加 `odd_name`、`league`/`league_en`、`home_team*`、`guest_team*`、`member_id`、`first_name`、`user_from`;`odd_name`/`odd_value`/`odd` 已按列表格式化 |
+| `admin.orders.lhc` | `/admin/lhcOrder/list` 单行 | `lhc_order` 字段、`first_name`、`member_id`、`user_from`、`items`(下注明细) |
+| `admin.orders.sport_game` | `/admin/sportGameOrder/list` 单行 | `sport_game_order` 字段、`first_name`、`member_id`、`game_name`、`gameplay_name` |
+| `admin.orders.jisu_game` | `/admin/jisuGameOrder/list` 单行 | `jisu_game_order` 字段、`first_name`、`member_id`、`gameplay`、`number`、`open_code`、`open_code_other` |
+| `admin.orders.third_game` | `/admin/thirdGame/orders` 的 `list[]` 单行 | `id`、`user_id`、`member_id`、`username`、`first_name`、`player_id`、`platform`、`currency`、`game_type`、`game_type_text`、`game_name`、`round`、`table`、`seat`、`bet_amount`、`valid_amount`、`settled_amount`、`bet_content`、`status`、`status_text`、`game_order_id`、`bet_time`、`last_update_time` |
+| `admin.orders.bet` | `/admin/bet` 单行 | `bets` 字段、`user`、`is_winner`、`winning_array`,`created_at` 为 `Y-m-d H:i` |
+| `admin.orders.recharge` | `/admin/wallet` 单行 | `recharges` 字段、`member`(会员信息) |
+| `admin.orders.payment` | `/admin/paymentOrder` 单行 | `payment_orders` 字段、`first_name`、`user_admin_note` |
+| `admin.orders.third_party_deposit` | `/admin/thirdPartyDeposit` 单行 | `id`、`member_id`、`username`、`first_name`、`payment_company`、`payment_company_name`、`payment_method`、`channel_code`、`amount`、`fee`、`before_balance`、`after_balance`、`order_no`、`gateway_order_no`、`submitted_at`、`credited_at`、`platform`、`status`、`status_text` |
+| `admin.orders.payment_withdraw` | `/admin/withdraw/rmb` 单行(按订单的 `type` 取 2 或 4) | `payment_orders` 字段、`first_name`、`user_admin_note` |
+| `admin.orders.manual_audit` | `/admin/manualAudit` 单行 | `id`、`request_id`、`admin_id`、`member_id`、`username`、`first_name`、`transaction_type(_text)`、`change_type`、`level_before/after(_name)`、`amount`、`signed_amount`、`before_balance`、`after_balance`、`status(_text)`、`remark`、`failure_reason`、`created_at` |
+| `admin.users` | `/admin/user` 单行 | `users` 字段、`wallet`、`level`、`agent`、`total_consume`、`third_game_total_balance` |
+
+字段含义与各列表接口文档一致,这里不重复。
+
+## 5. 前端处理建议
+
+- **按 `key` 定位行**:`created` 时行不存在就插入、已存在就替换;`updated` 时替换,行不在当前页可以忽略(或标记「有更新」);`deleted` / `offline` 时删除。
+- **对照当前筛选条件**:`created` / `updated` 的行如果不满足页面当前的搜索条件(状态、会员、时间范围等),不要插入(已在表里的可以移除);只在第一页、且排序为默认倒序时把新行插到顶部,其它页提示「有 N 条新记录」并让管理员手动刷新。
+- **合计、总数**:列表接口返回的 `total`、`total_amount` 等汇总值推送里没有,需要时节流重新请求一次列表,或前端自行加减。
+- **去重**:用 `channel + id` 去重;同一 `channel + key` 收到多次时总以最后到达的 `data` 为准(`data` 总是最新状态)。
+- **记录 `last_event_id`**:保存收到过的最大事件 `id`;重连后在 `subscribe` 里带上它,网关会补发期间的事件。
+- **收到 `resync`、或换票失败后重新连上时**:重新请求一次当前页列表,再继续接收事件。网关重启、断线时间过长(默认保留最近 5000 条事件)都会触发。
+- **页面切换**:离开列表页时 `unsubscribe`,进入时 `subscribe`;整个后台共用一条连接即可。
+- **推送不可用时**:换票失败或连接持续失败时退回原有的手动刷新 / 轮询,不影响页面使用。
+
+参考实现:
+
+```js
+class AdminRealtime {
+  constructor(http) {
+    this.http = http;                 // 已带后台 token 的请求实例
+    this.handlers = new Map();        // channel => fn(event)
+    this.resyncHandlers = new Map();  // channel => fn()
+    this.lastEventId = null;
+    this.retry = 0;
+  }
+
+  on(channel, onEvent, onResync) {
+    this.handlers.set(channel, onEvent);
+    this.resyncHandlers.set(channel, onResync);
+    this.send({ type: 'subscribe', channels: [channel], last_event_id: this.lastEventId });
+  }
+
+  off(channel) {
+    this.handlers.delete(channel);
+    this.resyncHandlers.delete(channel);
+    this.send({ type: 'unsubscribe', channels: [channel] });
+  }
+
+  async connect() {
+    let res;
+    try {
+      res = await this.http.get('/admin/realtime/ticket');
+    } catch (e) {
+      return this.reconnect();
+    }
+    if (res.code !== 0) return; // 未启用推送,保持 HTTP 列表
+    const ws = new WebSocket(`${res.data.url}?ticket=${encodeURIComponent(res.data.ticket)}`);
+    this.ws = ws;
+    ws.onmessage = (e) => this.onMessage(JSON.parse(e.data));
+    ws.onopen = () => {
+      this.retry = 0;
+      const channels = [...this.handlers.keys()];
+      // 重连时带上 last_event_id:网关补发期间事件,或回 resync 让页面重新拉列表
+      if (channels.length) this.send({ type: 'subscribe', channels, last_event_id: this.lastEventId });
+    };
+    ws.onclose = (e) => {
+      if (e.code === 4401 && e.reason === 'session expired') return; // 交给登录失效流程
+      this.reconnect();
+    };
+  }
+
+  reconnect() {
+    const delay = [1000, 2000, 5000, 10000, 30000][Math.min(this.retry++, 4)];
+    setTimeout(() => this.connect(), delay);
+  }
+
+  onMessage(msg) {
+    if (msg.type === 'welcome' && this.lastEventId === null) this.lastEventId = msg.last_event_id;
+    if (msg.type === 'event') {
+      this.lastEventId = Math.max(this.lastEventId ?? 0, msg.id);
+      this.handlers.get(msg.channel)?.(msg);
+    }
+    if (msg.type === 'resync') msg.channels.forEach((c) => this.resyncHandlers.get(c)?.());
+  }
+
+  send(payload) {
+    if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(payload));
+  }
+}
+
+// 体彩订单列表页
+realtime.on('admin.orders.sport', (e) => {
+  const index = rows.findIndex((r) => r.id === e.key.id);
+  if (e.action === 'deleted') return index >= 0 && rows.splice(index, 1);
+  if (!matchesFilters(e.data)) return index >= 0 && rows.splice(index, 1);
+  if (index >= 0) rows.splice(index, 1, e.data);
+  else if (e.action === 'created' && page === 1) rows.unshift(e.data);
+}, () => fetchList());
+```
+
+## 6. 覆盖范围(bot-28 内的写入点)
+
+bot-28 内以下写入会产生事件:
+
+- **所有经 Eloquent 模型的新增、修改、删除**(`BaseModel` 钩子,覆盖 `sport_order`、`lhc_order`、`sport_game_order`、`jisu_game_order`、`third_game_orders`、`bets`、`recharges`、`payment_orders`、`withdraws`、`manual_audits`):后台审核、退款、人工上下分、Telegram 机器人下注 / 充值 / 提现、各支付回调中以模型保存的部分等。只改 `updated_at`、`is_send`(通知标记)这类列时不推送。
+- **用户新增**:`User` 模型创建(含 Telegram 注册 `PublicService`)。用户资料的其它修改不推送。
+- **不经模型的批量写入,已单独登记**:
+  - 六合彩开奖结算、开奖失败退分(`BetService`)
+  - 足球游戏批量判定未中奖(`SportGameOrderJob`)
+  - 三方游戏订单每分钟同步(只推新订单或状态、结算金额、更新时间、会员归属有变化的订单)及旧账号回填
+  - USDT 充值链上确认、忽略无效充值、充值登记(`RechargeService`、`RechargeBonusService`)
+  - 支付通道代付回调的状态更新(`PaymentOrderService`)
+  - 后台批量设置打款信息、批量锁定 / 解锁提现与代付(`PaymentOrder`、`Withdraw` 控制器)
+  - 会员合并时转移的投注与支付订单(`SecretService`)
+- **在线状态**:不依赖写入点,由拉取接口定期比对在线集合,bot-28 与 melbet_sport-api 的登录、活跃都能覆盖。
+
+以后新增不经模型的批量写入时,在写入后调用:
+
+```php
+\App\Services\AdminRealtime\AdminRealtime::record('payment_orders', $ids);            // 默认 updated
+\App\Services\AdminRealtime\AdminRealtime::record('withdraws', $id, 'created');
+```
+
+它在事务内会等提交后再写,回滚则丢弃,失败只记日志。
+
+## 7. melbet_sport-api 的写入(跨仓库)
+
+会员前台 API(melbet_sport-api)也直接写这些表:体彩下单与结算(`SportOrder`)、六合彩 / 极速 / 足球游戏下注、投注、充值、代收代付、提现、**会员注册**。**这些写入目前不会产生事件**,在该仓库接入前,前台产生的新订单、新用户不会实时出现在后台列表(列表本身照常,刷新可见)。在线状态不受影响。
+
+接入方式:在 melbet_sport-api 对应写入之后往同一张表插入一行即可,不需要新增依赖或配置。以 ThinkPHP 模型事件为例:
+
+```php
+// app/common/service/AdminRealtimeEvent.php
+namespace app\common\service;
+
+use think\facade\Db;
+use think\facade\Log;
+
+class AdminRealtimeEvent
+{
+    /** $resource 为不带前缀的表名,如 sport_order、payment_orders、users */
+    public static function push(string $resource, $id, string $action = 'updated'): void
+    {
+        try {
+            Db::name('admin_realtime_events')->insert([
+                'resource' => $resource, 'resource_id' => (string) $id, 'action' => $action,
+                'source' => 'melbet_sport-api', 'created_at' => date('Y-m-d H:i:s'),
+            ]);
+        } catch (\Throwable $e) {
+            Log::error('admin_realtime_event_failed: ' . $e->getMessage());
+        }
+    }
+}
+
+// app/common/model/SportOrder.php
+public static function onAfterInsert($model) { AdminRealtimeEvent::push('sport_order', $model->id, 'created'); }
+public static function onAfterUpdate($model) { AdminRealtimeEvent::push('sport_order', $model->id); }
+```
+
+注意:
+
+- `resource` 只能是第 6 节列出的表名(`users` 只处理 `created`);`action` 用 `created` / `updated` / `deleted`。时间使用 Asia/Shanghai。
+- ThinkPHP 的模型事件只在模型 `save()` / `create()` 时触发,`Db::name()->update()` 这类批量写入需要在写入后手动调用 `push()`。
+- 在事务内插入也可以(回滚即不推送)。网关会把暂未提交的事件 id 记下,在 60 秒内补拉;事务持续超过 60 秒的事件可能漏推,长事务建议提交后再插入。
+- 插入失败必须吞掉异常,不能影响下单、支付。
+
+## 8. 部署
+
+### 8.1 后台(Laravel)
+
+```bash
+php artisan migrate --path=database/migrations/2026_10_31_160000_create_admin_realtime_events.php --force
+```
+
+`.env` 新增(示例见 `example.env`):
+
+| 变量 | 说明 |
+| --- | --- |
+| `ADMIN_WS_ENABLED` | `true` 开启。未执行迁移、未启动网关时保持 `false`,此时不登记任何事件 |
+| `ADMIN_WS_PUBLIC_URL` | 前端连接地址,如 `wss://admin.example.com/admin-ws` |
+| `ADMIN_WS_SECRET` | 票据签名密钥,后台与网关一致;留空则用 `JWT_SECRET` |
+| `ADMIN_WS_INTERNAL_SECRET` | 网关调用内部接口的密钥,必填(随机长字符串) |
+| `ADMIN_WS_ONLINE_SCAN_SECONDS` | 在线状态扫描间隔,默认 `15` |
+| `ADMIN_WS_RETENTION_HOURS` | 事件表保留时长,默认 `72` |
+
+改完执行 `php artisan config:clear`(线上有配置缓存时为 `config:cache`)。调度器已加入每小时 `admin-realtime:prune` 清理过期事件,沿用现有 `schedule:run` 定时任务即可。
+
+`GET /admin/realtime/events` 是给网关用的内部接口,凭 `X-Realtime-Secret` 访问;建议在 Nginx 上只允许本机访问:
+
+```nginx
+location = /admin/realtime/events {
+    allow 127.0.0.1;
+    deny all;
+    try_files $uri /index.php?$query_string;
+}
+```
+
+### 8.2 推送网关(Node ≥ 18)
+
+```bash
+cd realtime && npm ci --omit=dev
+```
+
+网关默认读取项目根目录 `.env`(可用 `ADMIN_WS_ENV_FILE` 指定),也可以直接用环境变量:
+
+| 变量 | 默认 | 说明 |
+| --- | --- | --- |
+| `ADMIN_WS_HOST` | `127.0.0.1` | 监听地址,由 Nginx 反代,不直接对外 |
+| `ADMIN_WS_PORT` | `6002` | 监听端口 |
+| `ADMIN_WS_PATH` | `/admin-ws` | WebSocket 路径 |
+| `ADMIN_WS_LARAVEL_URL` | `APP_URL` | 网关访问后台的地址,建议 `http://127.0.0.1` |
+| `ADMIN_WS_LARAVEL_HOST` | 空 | 用 IP 访问后台、Nginx 需要按域名匹配站点时填写后台域名(作为 Host 头) |
+| `ADMIN_WS_POLL_MS` | `1000` | 拉取间隔(毫秒) |
+| `ADMIN_WS_ALLOWED_ORIGINS` | 空(不限制) | 允许的前端 Origin,逗号分隔,如 `https://admin.example.com` |
+| `ADMIN_WS_REPLAY_SIZE` | `5000` | 用于断线补发的最近事件条数 |
+
+进程守护(二选一,只运行**一个**网关进程):
+
+```ini
+; /etc/supervisor/conf.d/bot28-admin-ws.conf
+[program:bot28-admin-ws]
+directory=/www/wwwroot/bot-28/realtime
+command=node admin-gateway.js
+autostart=true
+autorestart=true
+stopsignal=TERM
+user=www
+redirect_stderr=true
+stdout_logfile=/www/wwwroot/bot-28/storage/logs/realtime/gateway.log
+```
+
+```bash
+pm2 start realtime/admin-gateway.js --name bot28-admin-ws && pm2 save
+```
+
+Nginx 反代(后台域名的 server 块内):
+
+```nginx
+location /admin-ws {
+    proxy_pass http://127.0.0.1:6002;
+    proxy_http_version 1.1;
+    proxy_set_header Upgrade $http_upgrade;
+    proxy_set_header Connection "upgrade";
+    proxy_set_header Host $host;
+    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    proxy_read_timeout 120s;
+}
+```
+
+健康检查(在服务器本机执行):
+
+```bash
+curl -s http://127.0.0.1:6002/health
+```
+
+返回 `ok: true`、`cursor` 随业务增长即正常;`last_error` 为拉取内部接口的最近错误(如 `HTTP 403` 表示内部密钥不一致,`code=… 实时推送未启用` 表示后台 `ADMIN_WS_ENABLED` 未开启)。
+
+### 8.3 发布顺序
+
+1. 发布代码,执行迁移,配置 `.env`,`ADMIN_WS_ENABLED` 先保持 `false`。
+2. 安装并启动网关,配置 Nginx。
+3. 将 `ADMIN_WS_ENABLED` 改为 `true` 并刷新配置缓存,`/health` 正常后通知前端上线。
+
+关闭推送:`ADMIN_WS_ENABLED=false` 并刷新配置即可,业务写入不再登记事件,网关可保留运行。
+
+### 8.4 本地开发
+
+```bash
+php artisan migrate
+```
+
+```bash
+cd realtime && npm install && node admin-gateway.js
+```
+
+`.env` 中设置 `ADMIN_WS_ENABLED=true`、`ADMIN_WS_INTERNAL_SECRET=任意值`、`ADMIN_WS_PUBLIC_URL=ws://127.0.0.1:6002/admin-ws`、`ADMIN_WS_LARAVEL_URL` 为本地后台地址。
+
+## 9. 验证情况
+
+- `tests/Integration/AdminRealtimeTest.php`(隔离 SQLite):事务提交后才登记、回滚丢弃、关闭时不登记、事件表缺失不影响业务事务、模型钩子只推订单与新用户、推送行与 `/admin/withdraw` 列表行逐字段一致、删除只带主键、按 gaps 补拉、票据签名与 Node 网关算法互通。
+- 网关用模拟内部接口做了端到端验证:无效 / 重复票据 4401、无权限频道被拒、未提交事件补拉后送达、按 `last_event_id` 补发、历史不足时 `resync`、后台登录过期断开、健康检查。
+- 在线用户扫描与各订单列表的单行解析依赖 MySQL 语法(`COLLATE` 等),未在真实数据库、真实 Nginx / wss 环境下验证;上线后请按 8.3 用 `/health` 与后台页面实际下单核对。

+ 17 - 0
example.env

@@ -124,6 +124,23 @@ ZIMU_CASH_QUERY_GATEWAY=https://api.zimu808.com/api/v1/mch/openapi/cash/cashInfo
 # 留空表示全部用户可用;多个 users.id 使用英文逗号分隔
 ZIMU_PAY_RECHARGE_USER_IDS=
 
+# 后台订单/用户列表实时推送(docs/admin-websocket.md)
+# 执行迁移并启动 realtime/admin-gateway.js 后再设为 true
+ADMIN_WS_ENABLED=false
+# 前端连接地址(经 Nginx 反代到网关端口)
+ADMIN_WS_PUBLIC_URL=wss://admin.example.com/admin-ws
+# 连接票据签名密钥,网关与后台必须一致;留空则使用 JWT_SECRET
+ADMIN_WS_SECRET=
+# 网关调用 /admin/realtime/events 的内部密钥,必填
+ADMIN_WS_INTERNAL_SECRET=
+# 以下供网关进程读取
+ADMIN_WS_PORT=6002
+ADMIN_WS_PATH=/admin-ws
+ADMIN_WS_LARAVEL_URL=http://127.0.0.1
+ADMIN_WS_POLL_MS=1000
+ADMIN_WS_ONLINE_SCAN_SECONDS=15
+ADMIN_WS_RETENTION_HOURS=72
+
 # 数据库
 DB_CONNECTION=mysql
 DB_HOST=127.0.0.1

+ 315 - 0
realtime/admin-gateway.js

@@ -0,0 +1,315 @@
+'use strict';
+
+/**
+ * 后台实时推送网关(订单 / 用户列表),说明见 docs/admin-websocket.md。
+ *
+ * 1. 前端用 GET /admin/realtime/ticket 换取票据后连接 ws(s)://host/admin-ws?ticket=xxx;
+ * 2. 网关用 ADMIN_WS_SECRET 校验票据签名,只允许订阅票据内的频道;
+ * 3. 网关每 ADMIN_WS_POLL_MS 调用 Laravel 内部接口 /admin/realtime/events 拉取已解析好的列表行并推送。
+ *
+ * 配置读取顺序:进程环境变量 > 项目根目录 .env(可用 ADMIN_WS_ENV_FILE 指定)。
+ */
+
+const crypto = require('crypto');
+const fs = require('fs');
+const http = require('http');
+const https = require('https');
+const path = require('path');
+const { WebSocketServer, WebSocket } = require('ws');
+
+loadEnvFile(process.env.ADMIN_WS_ENV_FILE || path.resolve(__dirname, '..', '.env'));
+
+const config = {
+    host: env('ADMIN_WS_HOST', '127.0.0.1'),
+    port: Number(env('ADMIN_WS_PORT', '6002')),
+    path: env('ADMIN_WS_PATH', '/admin-ws'),
+    laravelUrl: env('ADMIN_WS_LARAVEL_URL', env('APP_URL', 'http://127.0.0.1')).replace(/\/+$/, ''),
+    laravelHost: env('ADMIN_WS_LARAVEL_HOST', ''),
+    pollMs: Math.max(200, Number(env('ADMIN_WS_POLL_MS', '1000'))),
+    ticketSecret: env('ADMIN_WS_SECRET', '') || env('JWT_SECRET', ''),
+    internalSecret: env('ADMIN_WS_INTERNAL_SECRET', ''),
+    allowedOrigins: env('ADMIN_WS_ALLOWED_ORIGINS', '').split(',').map((s) => s.trim()).filter(Boolean),
+    replaySize: Math.max(100, Number(env('ADMIN_WS_REPLAY_SIZE', '5000'))),
+    gapTtlMs: 60000,
+    heartbeatMs: 25000,
+};
+
+if (!config.ticketSecret || !config.internalSecret) {
+    log('error', 'ADMIN_WS_SECRET(或 JWT_SECRET) 与 ADMIN_WS_INTERNAL_SECRET 必须配置');
+    process.exit(1);
+}
+
+const clients = new Set();
+const usedNonces = new Map(); // nonce => 过期时间戳,票据一次性
+const gaps = new Map(); // 未出现的事件 id => 首次发现时间
+const buffer = []; // 最近推送过的事件,用于断线重连补发
+const state = { cursor: -1, historyFrom: -1, lastPollAt: null, lastError: null, polling: false };
+
+// ---------------------------------------------------------------- WebSocket
+
+const server = http.createServer((req, res) => {
+    if (req.url === '/health') {
+        res.writeHead(200, { 'Content-Type': 'application/json' });
+        res.end(JSON.stringify({
+            ok: state.lastError === null, connections: clients.size, cursor: state.cursor, history_from: state.historyFrom,
+            gaps: gaps.size, buffered: buffer.length, last_poll_at: state.lastPollAt, last_error: state.lastError,
+        }));
+        return;
+    }
+    res.writeHead(404);
+    res.end();
+});
+
+const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
+
+server.on('upgrade', (req, socket, head) => {
+    const url = new URL(req.url, 'http://localhost');
+    if (url.pathname !== config.path) {
+        socket.destroy();
+        return;
+    }
+    if (config.allowedOrigins.length && !config.allowedOrigins.includes(req.headers.origin)) {
+        socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
+        socket.destroy();
+        return;
+    }
+    const ticket = verifyTicket(url.searchParams.get('ticket'));
+    wss.handleUpgrade(req, socket, head, (ws) => {
+        // 握手完成后再关闭,前端可凭 4401 区分「票据无效,需要重新换票」
+        if (!ticket) {
+            ws.close(4401, 'invalid ticket');
+            return;
+        }
+        onConnection(ws, ticket, req);
+    });
+});
+
+function onConnection(ws, ticket, req) {
+    const client = {
+        ws, adminId: ticket.aid, allowed: new Set(ticket.ch || []), subs: new Set(), alive: true, timer: null,
+        ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress,
+    };
+    clients.add(client);
+    if (ticket.sxp) {
+        const ms = ticket.sxp * 1000 - Date.now();
+        client.timer = setTimeout(() => ws.close(4401, 'session expired'), Math.max(0, Math.min(ms, 2147483647)));
+    }
+    log('info', `connect admin=${client.adminId} ip=${client.ip} total=${clients.size}`);
+
+    ws.on('pong', () => { client.alive = true; });
+    ws.on('message', (raw) => onMessage(client, raw));
+    ws.on('close', () => {
+        clearTimeout(client.timer);
+        clients.delete(client);
+        log('info', `disconnect admin=${client.adminId} total=${clients.size}`);
+    });
+    ws.on('error', () => {});
+
+    send(client, { type: 'welcome', admin_id: client.adminId, channels: [...client.allowed], last_event_id: state.cursor, server_time: now() });
+}
+
+function onMessage(client, raw) {
+    let msg;
+    try {
+        msg = JSON.parse(String(raw));
+    } catch (e) {
+        send(client, { type: 'error', message: 'invalid json' });
+        return;
+    }
+    const channels = Array.isArray(msg.channels) ? msg.channels.map(String) : [];
+    switch (msg.type) {
+        case 'ping':
+            send(client, { type: 'pong', server_time: now() });
+            return;
+        case 'subscribe': {
+            const granted = channels.filter((c) => client.allowed.has(c));
+            const denied = channels.filter((c) => !client.allowed.has(c));
+            granted.forEach((c) => client.subs.add(c));
+            send(client, { type: 'subscribed', channels: granted, denied, last_event_id: state.cursor });
+            if (granted.length && msg.last_event_id !== undefined && msg.last_event_id !== null) {
+                replay(client, granted, Number(msg.last_event_id));
+            }
+            return;
+        }
+        case 'unsubscribe':
+            channels.forEach((c) => client.subs.delete(c));
+            send(client, { type: 'unsubscribed', channels });
+            return;
+        default:
+            send(client, { type: 'error', message: 'unknown type' });
+    }
+}
+
+/** 断线期间的事件仍在缓冲区内就补发,否则通知前端重新拉列表 */
+function replay(client, channels, lastEventId) {
+    if (!Number.isFinite(lastEventId) || state.historyFrom < 0 || lastEventId < state.historyFrom) {
+        send(client, { type: 'resync', channels, reason: 'history_unavailable' });
+        return;
+    }
+    const wanted = new Set(channels);
+    buffer.filter((e) => e.id > lastEventId && wanted.has(e.channel)).forEach((e) => send(client, e));
+}
+
+function broadcast(event) {
+    for (const client of clients) {
+        if (client.subs.has(event.channel)) send(client, event);
+    }
+}
+
+function send(client, payload) {
+    if (client.ws.readyState === WebSocket.OPEN) client.ws.send(JSON.stringify(payload));
+}
+
+setInterval(() => {
+    for (const client of clients) {
+        if (!client.alive) {
+            client.ws.terminate();
+            continue;
+        }
+        client.alive = false;
+        client.ws.ping();
+    }
+    const t = now();
+    for (const [nonce, exp] of usedNonces) if (exp < t) usedNonces.delete(nonce);
+}, config.heartbeatMs).unref();
+
+// ---------------------------------------------------------------- 票据
+
+function verifyTicket(ticket) {
+    const [body, sig] = String(ticket || '').split('.');
+    if (!body || !sig) return null;
+    const expected = crypto.createHmac('sha256', config.ticketSecret).update(body).digest('base64url');
+    if (expected.length !== sig.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return null;
+    let payload;
+    try {
+        payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
+    } catch (e) {
+        return null;
+    }
+    if (!payload || !payload.aid || !payload.exp || payload.exp < now() || !payload.nonce || usedNonces.has(payload.nonce)) return null;
+    if (payload.sxp && payload.sxp < now()) return null;
+    usedNonces.set(payload.nonce, payload.exp);
+    return payload;
+}
+
+// ---------------------------------------------------------------- 拉取事件
+
+async function poll() {
+    if (state.polling) return;
+    state.polling = true;
+    let delay = config.pollMs;
+    try {
+        const query = new URLSearchParams({ after: String(state.cursor) });
+        if (gaps.size) query.set('gaps', [...gaps.keys()].slice(0, 500).join(','));
+        const body = await getJson(`${config.laravelUrl}/admin/realtime/events?${query}`);
+        if (Number(body.code) !== 0) throw new Error(`code=${body.code} msg=${body.msg}`);
+        handleBatch(body.data || {});
+        state.lastError = null;
+        if (body.data && body.data.has_more) delay = 0;
+    } catch (e) {
+        if (state.lastError !== e.message) log('error', `poll failed: ${e.message}`);
+        state.lastError = e.message;
+        delay = Math.min(config.pollMs * 5, 10000);
+    } finally {
+        state.lastPollAt = new Date().toISOString();
+        state.polling = false;
+        setTimeout(poll, delay);
+    }
+}
+
+function handleBatch(data) {
+    const lastId = Number(data.last_id || 0);
+    const seen = new Set((data.ids || []).map(Number));
+    seen.forEach((id) => gaps.delete(id));
+
+    if (state.cursor < 0) {
+        // 刚启动:从当前最大 id 开始,不回放历史
+        state.cursor = lastId;
+        state.historyFrom = lastId;
+        log('info', `start from event id ${lastId}`);
+        return;
+    }
+    // 跳过的 id 可能是写入方事务尚未提交,记为 gap 稍后补拉
+    if (lastId > state.cursor && lastId - state.cursor <= 10000) {
+        const t = Date.now();
+        for (let id = state.cursor + 1; id < lastId; id++) if (!seen.has(id)) gaps.set(id, t);
+    }
+    state.cursor = Math.max(state.cursor, lastId);
+    const t = Date.now();
+    for (const [id, at] of gaps) if (t - at > config.gapTtlMs) gaps.delete(id);
+
+    for (const event of data.events || []) {
+        const message = { type: 'event', ...event };
+        buffer.push(message);
+        broadcast(message);
+    }
+    if (buffer.length > config.replaySize) {
+        const removed = buffer.splice(0, buffer.length - config.replaySize);
+        state.historyFrom = Math.max(state.historyFrom, removed[removed.length - 1].id);
+    }
+}
+
+function getJson(url) {
+    return new Promise((resolve, reject) => {
+        const target = new URL(url);
+        const headers = { Accept: 'application/json', 'X-Realtime-Secret': config.internalSecret };
+        if (config.laravelHost) headers.Host = config.laravelHost;
+        const req = (target.protocol === 'https:' ? https : http).request(target, { method: 'GET', headers, timeout: 20000 }, (res) => {
+            let raw = '';
+            res.setEncoding('utf8');
+            res.on('data', (chunk) => { raw += chunk; });
+            res.on('end', () => {
+                if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
+                try {
+                    resolve(JSON.parse(raw));
+                } catch (e) {
+                    reject(new Error('invalid json from laravel'));
+                }
+            });
+        });
+        req.on('timeout', () => req.destroy(new Error('timeout')));
+        req.on('error', reject);
+        req.end();
+    });
+}
+
+// ---------------------------------------------------------------- 工具
+
+function now() {
+    return Math.floor(Date.now() / 1000);
+}
+
+function env(key, fallback) {
+    const value = process.env[key];
+    return value === undefined || value === '' ? fallback : value;
+}
+
+function loadEnvFile(file) {
+    if (!fs.existsSync(file)) return;
+    for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
+        const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
+        if (!match || process.env[match[1]] !== undefined) continue;
+        let value = match[2];
+        if (/^(['"]).*\1$/.test(value)) value = value.slice(1, -1);
+        else value = value.replace(/\s+#.*$/, '').trim();
+        process.env[match[1]] = value;
+    }
+}
+
+function log(level, message) {
+    (level === 'error' ? console.error : console.log)(`[${new Date().toISOString()}] [admin-ws] ${level}: ${message}`);
+}
+
+function shutdown() {
+    for (const client of clients) client.ws.close(1012, 'server restart');
+    server.close(() => process.exit(0));
+    setTimeout(() => process.exit(0), 3000).unref();
+}
+
+process.on('SIGTERM', shutdown);
+process.on('SIGINT', shutdown);
+
+server.listen(config.port, config.host, () => {
+    log('info', `listening on ${config.host}:${config.port}${config.path}, laravel=${config.laravelUrl}`);
+    poll();
+});

+ 37 - 0
realtime/package-lock.json

@@ -0,0 +1,37 @@
+{
+  "name": "bot-28-admin-realtime",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "bot-28-admin-realtime",
+      "dependencies": {
+        "ws": "^8.18.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/ws": {
+      "version": "8.22.1",
+      "resolved": "https://registry.npmmirror.com/ws/-/ws-8.22.1.tgz",
+      "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": ">=5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    }
+  }
+}

+ 15 - 0
realtime/package.json

@@ -0,0 +1,15 @@
+{
+  "name": "bot-28-admin-realtime",
+  "private": true,
+  "description": "后台订单/用户列表实时推送网关,见 docs/admin-websocket.md",
+  "main": "admin-gateway.js",
+  "scripts": {
+    "start": "node admin-gateway.js"
+  },
+  "engines": {
+    "node": ">=18"
+  },
+  "dependencies": {
+    "ws": "^8.18.0"
+  }
+}

+ 7 - 0
routes/admin.php

@@ -59,6 +59,7 @@ use App\Http\Controllers\admin\SiteMessage;
 use App\Http\Controllers\admin\Maintenance;
 use App\Http\Controllers\admin\Reporting;
 use App\Http\Controllers\admin\RewardGrantSettings;
+use App\Http\Controllers\admin\Realtime;
 
 Route::post('/login', [Admin::class, 'login']);
 Route::get('/test', [Wallet::class, 'test']);
@@ -75,6 +76,9 @@ Route::prefix('/sync')->group(function () {
     Route::get('/exchange', [Sync::class, 'exchange']);
 });
 
+// 后台实时推送网关内部拉取事件(X-Realtime-Secret 鉴权,网关每秒调用,不走 api 限流)
+Route::get('/realtime/events', [Realtime::class, 'events'])->withoutMiddleware('throttle:api');
+
 Route::get('/fake', [Bet::class, 'fake']);
 Route::get('/fakel', [Issue::class, 'fakeLotteryDraw']);
 
@@ -554,6 +558,9 @@ Route::middleware(['admin.jwt'])->group(function () {
     });
 
 
+    // 后台实时推送:换取 WebSocket 连接票据(登录即可,频道按列表按钮权限过滤)
+    Route::get('/realtime/ticket', [Realtime::class, 'ticket']);
+
     Route::prefix('/upload')->group(function () {
         Route::post('/uploadFile', [Upload::class, 'store']);
         Route::post('/uploadVideo', [Upload::class, 'video']);

+ 188 - 0
tests/Integration/AdminRealtimeTest.php

@@ -0,0 +1,188 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\Withdraw as WithdrawController;
+use App\Models\User;
+use App\Models\Withdraw;
+use App\Services\AdminRealtime\AdminRealtime;
+use App\Services\AdminRealtime\AdminRealtimeFeed;
+use App\Services\AdminRealtime\AdminRealtimeRows;
+use App\Services\AdminRealtime\AdminRealtimeTicket;
+use Carbon\Carbon;
+use Illuminate\Cache\ArrayStore;
+use Illuminate\Cache\Repository as CacheRepository;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\DatabaseTransactionsManager;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Events\Dispatcher;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\JsonResponse;
+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 Illuminate\Validation\Factory;
+use PHPUnit\Framework\TestCase;
+
+class AdminRealtimeTest extends TestCase
+{
+    private Application $app;
+    private object $log;
+
+    protected function setUp(): void
+    {
+        Carbon::setTestNow(Carbon::parse('2026-10-31 10:00:00', 'Asia/Shanghai'));
+        Facade::clearResolvedInstances();
+        Model::clearBootedModels();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository([
+            'app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'],
+            'admin_realtime' => ['enabled' => true, 'ticket_secret' => 'ticket-secret', 'ticket_ttl' => 60, 'public_url' => 'wss://admin.test/admin-ws', 'batch_size' => 200, 'online_scan_seconds' => 15, 'retention_hours' => 72],
+        ]));
+        Facade::setFacadeApplication($this->app);
+        $events = new Dispatcher($this->app); $this->app->instance('events', $events);
+        $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setEventDispatcher($events); $db->setAsGlobal(); $db->bootEloquent();
+        $db->getConnection()->setTransactionManager(new DatabaseTransactionsManager());
+        $this->app->instance('db', $db->getDatabaseManager());
+        $this->app->bind('db.schema', fn () => $db->schema());
+        $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator);
+        $this->app->instance('validator', new Factory($translator, $this->app));
+        $this->log = new class {
+            public array $records = [];
+            public function channel($name = null) { return $this; }
+            public function __call($level, $args) { $this->records[] = [$level, $args[0] ?? '']; }
+        };
+        $this->app->instance('log', $this->log);
+        $this->app->instance('cache', new CacheRepository(new ArrayStore()));
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
+            public function json($data, $status = 200, $headers = [], $options = 0) { return new JsonResponse($data, $status, $headers, $options); }
+        });
+        Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
+        $this->app->instance('request', Request::create('/admin/realtime/events', 'GET'));
+
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_31_160000_create_admin_realtime_events.php')->up();
+        Schema::create('users', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->string('user_id')->default(''); $t->string('first_name')->default('');
+            $t->string('admin_note')->nullable(); $t->string('language')->nullable(); $t->integer('recharge_channel_group_id')->nullable(); $t->timestamps();
+        });
+        Schema::create('withdraws', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->decimal('amount', 20, 10)->default(0); $t->decimal('service_charge', 20, 10)->default(0);
+            $t->decimal('to_account', 20, 10)->default(0); $t->string('address')->default(''); $t->decimal('exchange_rate', 20, 10)->default(0);
+            $t->integer('status')->default(0); $t->decimal('after_balance', 20, 10)->default(0); $t->string('remark')->nullable();
+            $t->string('admin_note')->nullable(); $t->integer('is_locked')->default(0); $t->integer('is_send')->default(0); $t->timestamps();
+        });
+        DB::table('users')->insert(['member_id' => '9001', 'user_id' => 'u9001', 'first_name' => 'Alice', 'admin_note' => 'vip']);
+    }
+
+    protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
+
+    private function events(): array
+    {
+        return DB::table('admin_realtime_events')->orderBy('id')->get()->map(fn ($r) => "{$r->resource}:{$r->resource_id}:{$r->action}")->all();
+    }
+
+    public function test_record_waits_for_commit_and_drops_on_rollback(): void
+    {
+        AdminRealtime::record('withdraws', 1);
+        $this->assertSame(['withdraws:1:updated'], $this->events());
+
+        DB::beginTransaction();
+        AdminRealtime::record('withdraws', [2, 2, 3], 'created');
+        $this->assertCount(1, $this->events(), '事务内不写入');
+        DB::commit();
+        $this->assertSame(['withdraws:1:updated', 'withdraws:2:created', 'withdraws:3:created'], $this->events());
+
+        DB::beginTransaction();
+        AdminRealtime::record('withdraws', 4);
+        DB::rollBack();
+        $this->assertCount(3, $this->events(), '回滚后丢弃');
+
+        $this->app['config']->set('admin_realtime.enabled', false);
+        AdminRealtime::record('withdraws', 5);
+        $this->assertCount(3, $this->events(), '关闭时不登记');
+    }
+
+    public function test_record_failure_never_breaks_business_transaction(): void
+    {
+        Schema::drop('admin_realtime_events');
+        DB::transaction(function () {
+            Withdraw::create(['member_id' => '9001', 'amount' => 10]);
+        });
+        $this->assertSame(1, Withdraw::count(), '事件表缺失也不影响主业务');
+        $this->assertSame('error', $this->log->records[0][0] ?? null);
+    }
+
+    public function test_model_hooks_capture_orders_and_new_users_only(): void
+    {
+        $withdraw = Withdraw::create(['member_id' => '9001', 'amount' => 10]);
+        $withdraw->update(['is_send' => 1]);
+        $withdraw->update(['status' => 1]);
+        $user = User::query()->create(['member_id' => '9002', 'first_name' => 'Bob']);
+        $user->first_name = 'Bobby'; $user->save();
+        $withdraw->delete();
+
+        $this->assertSame([
+            "withdraws:{$withdraw->id}:created", "withdraws:{$withdraw->id}:updated",
+            "users:{$user->id}:created", "withdraws:{$withdraw->id}:deleted",
+        ], $this->events());
+    }
+
+    public function test_feed_resolves_rows_with_the_list_endpoint_shape(): void
+    {
+        $this->assertSame(['last_id' => 0, 'ids' => [], 'events' => [], 'has_more' => false], AdminRealtimeFeed::pull(-1));
+
+        $withdraw = Withdraw::create(['member_id' => '9001', 'amount' => 12.5, 'to_account' => 12.5]);
+        $withdraw->update(['status' => 1]);
+        Withdraw::create(['member_id' => '9001', 'amount' => 3]);
+        $result = AdminRealtimeFeed::pull(0);
+
+        $this->assertSame(3, $result['last_id']);
+        $this->assertSame([1, 2, 3], $result['ids']);
+        $this->assertCount(2, $result['events'], '同一记录一批内合并');
+        $first = $result['events'][0];
+        $this->assertSame(['id' => 2, 'channel' => 'admin.orders.withdraw', 'event' => 'order.created', 'action' => 'created', 'key' => ['id' => 1]],
+            array_intersect_key($first, array_flip(['id', 'channel', 'event', 'action', 'key'])));
+
+        $listRequest = Request::create('/admin/withdraw', 'GET');
+        $this->app->instance('request', $listRequest);
+        $list = (new WithdrawController())->index()->getData(true)['data']['data'];
+        $row = collect($list)->firstWhere('id', 1);
+        $this->assertSame($row, $first['data'], '推送行与列表接口返回的行一致');
+        $this->assertSame('Alice', $first['data']['first_name']);
+        $this->assertSame(1, $first['data']['status']);
+
+        $withdraw->delete();
+        $deleted = AdminRealtimeFeed::pull(3)['events'];
+        $this->assertSame([['channel' => 'admin.orders.withdraw', 'action' => 'deleted', 'key' => ['id' => 1], 'data' => null]],
+            array_map(fn ($e) => array_intersect_key($e, array_flip(['channel', 'action', 'key', 'data'])), $deleted));
+
+        $gapped = AdminRealtimeFeed::pull(4, ['2']);
+        $this->assertSame([2], $gapped['ids'], '按 gaps 补拉已处理游标之前的事件');
+        $this->assertSame(4, $gapped['last_id']);
+        $this->assertNotEmpty(array_filter($this->log->records, fn ($r) => $r[1] === 'admin_realtime_online_scan_failed'),
+            'SQLite 不支持在线判定 SQL,扫描失败只记日志不影响拉取');
+    }
+
+    public function test_ticket_is_signed_for_gateway_and_lists_channels(): void
+    {
+        $ticket = AdminRealtimeTicket::issue(1, 1893456000);
+        [$body, $sig] = explode('.', $ticket['ticket']);
+        $this->assertSame(rtrim(strtr(base64_encode(hash_hmac('sha256', $body, 'ticket-secret', true)), '+/', '-_'), '='), $sig);
+        $payload = json_decode(base64_decode(strtr($body, '-_', '+/')), true);
+        $this->assertSame(1, $payload['aid']);
+        $this->assertSame(1893456000, $payload['sxp']);
+        $this->assertSame(array_keys(AdminRealtimeRows::CHANNELS), $payload['ch']);
+        $this->assertSame('wss://admin.test/admin-ws', $ticket['url']);
+
+        $node = trim((string) shell_exec('command -v node'));
+        if ($node === '') $this->markTestIncomplete('node 不可用,跳过网关签名互通校验');
+        $script = "const c=require('crypto');const [b,s]=process.argv[1].split('.');process.stdout.write(String(c.createHmac('sha256','ticket-secret').update(b).digest('base64url')===s))";
+        $this->assertSame('true', shell_exec(escapeshellarg($node) . ' -e ' . escapeshellarg($script) . ' ' . escapeshellarg($ticket['ticket'])));
+    }
+}