# 后台订单 / 用户列表 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= ``` | 关闭码 | 含义 | 前端处理 | | --- | --- | --- | | `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` 与后台页面实际下单核对。