# 后台订单 / 用户列表实时推送(Reverb + Laravel Echo) 后台各订单列表、用户列表、在线用户列表通过 WebSocket 接收「新增 / 变更 / 删除 / 上下线」事件。事件里带的 `data` 就是对应列表接口返回的那一行,前端可以直接插入或替换表格行,不用再请求一次列表。 2026-10-31:随 Laravel 13 升级,推送通道由自研 Node 网关(`realtime/admin-gateway.js`,原生 WebSocket + 一次性票据)改为 Laravel 官方的 [Reverb](https://laravel.com/docs/13.x/reverb) 与 [Laravel Echo](https://laravel.com/docs/13.x/broadcasting#client-side-installation)。事件表、列表行解析、频道与载荷的 `data` 均不变;前端需要按第 2、5 节改用 Echo;可直接交给后台前端的独立说明见[后台实时推送-前端接入](总后台/后台实时推送-前端接入.md)。 ## 1. 架构与选型 ``` 业务写入(下单、支付回调、结算、审核、注册……) │ 事务提交后登记一条「表 + 主键 + 动作」 ▼ bot_admin_realtime_events(事件表,bot-28 与 melbet_sport-api 都能写) │ php artisan admin-realtime:relay(常驻,单实例) │ 按 id 读取未广播的事件 → 分配连续 seq → 用列表接口自己的查询解析成列表行 │ 顺带:每 15 秒比对在线会员;每 5 秒断开 JWT 已过期的后台连接 ▼ Laravel 广播(ShouldBroadcastNow,内网 HTTP,不经队列) Reverb(php artisan reverb:start,常驻) │ wss://<后台域名>/app/(Pusher 协议) ▼ 后台前端 Laravel Echo(私有频道,按列表按钮权限授权) ``` | 关注点 | 由谁负责 | | --- | --- | | 连接、心跳、自动重连、频道订阅协议 | Reverb + Echo(Pusher 协议),不再自行维护 | | 频道授权 | `POST /admin/broadcasting/auth`:校验后台 JWT,按列表按钮权限签名 | | 登录过期断开 | 授权时把「管理员 id + JWT 过期时间」写进连接;relay 到期调用 Reverb 断开,重连授权返回 401 | | 断线补发、去重、重新同步列表 | 应用自己实现:事件带连续 `seq`,前端重连后调 `GET /admin/realtime/replay` 追赶,积压过多或历史已清理时返回 `resync` | | 晚提交事件(melbet 长事务) | relay 在 `ADMIN_WS_GAP_SECONDS` 内补发游标之前未广播的行,`seq` 按广播先后分配,补发接口不会漏 | 选型说明: - 事件表(outbox)与列表行解析保留。事件表放在共享业务库里,**melbet_sport-api 不需要任何新依赖**就能写入同一张表(见第 7 节),不用跟着换框架。 - 广播用 `ShouldBroadcastNow` 在 relay 进程内同步发送,**不需要队列 worker**;Reverb 不可用时广播失败只记日志,前端重连后凭 `seq` 补发。 - Reverb 只负责投递,不保存历史,所以 `seq`、补发接口和 `resync` 仍由应用实现。 - 事件登记放在事务提交之后(`DB::afterCommit`),事务回滚就不推送;登记、解析、广播任何一步失败都只记 `storage/logs/realtime/` 日志,不影响下单、充值等主业务。 ## 2. 接入流程 1. 登录后台后调用 `GET /admin/realtime/config`,取得 Echo 连接参数、可订阅频道、当前 `last_seq` 与登录过期时间。 2. 用返回参数创建 Echo(`broadcaster: 'reverb'`),授权地址 `/admin/broadcasting/auth`,请求头带后台 token。 3. 进入列表页时 `echo.private(channel).listen('.order.created', …)` 等;频道名不带 `private-` 前缀,事件名前面加 `.`。 4. **每次订阅成功(含自动重连后的重新订阅)**调用 `GET /admin/realtime/replay?after_seq=<收到过的最大 seq>&channels=<频道>` 追赶期间的事件;返回 `resync: true` 时重新请求一次列表。 5. 订阅失败且状态码为 401 时走后台登录失效流程,不要重试。 ### 2.1 连接参数 `GET /admin/realtime/config`,请求头 `Authorization: Bearer <后台 token>`,只要求后台登录态。 ```json { "code": 0, "timestamp": 1793444400, "msg": "OK", "data": { "broadcaster": "reverb", "key": "8f1c…", "ws_host": "admin.example.com", "ws_port": 443, "force_tls": true, "auth_endpoint": "/admin/broadcasting/auth", "channels": ["admin.orders.sport", "admin.orders.withdraw", "admin.users", "admin.users.online"], "last_seq": 1532, "session_expires_at": 1793530800 } } ``` | 字段 | 说明 | | --- | --- | | `key` | Reverb 应用 key(公开值,`REVERB_APP_KEY`)。`REVERB_APP_SECRET` 只在服务端 | | `ws_host` / `ws_port` / `force_tls` | Echo 的 `wsHost`、`wsPort`/`wssPort`、`forceTLS` | | `auth_endpoint` | 私有频道授权地址,相对后台 API 域名 | | `channels` | 当前管理员可订阅的频道,按各列表接口的按钮权限过滤(与 `check.button.uri` 口径一致,超级管理员 `id=1` 拥有全部频道);权限变更最迟 60 秒后生效 | | `last_seq` | 当前最大事件序号。首次订阅成功后以它为 `after_seq` 调补发接口,补上「拉列表」与「订阅完成」之间的事件 | | `session_expires_at` | 后台 token 过期时间戳。到期后服务端会断开连接,前端也可以据此提前处理 | 未启用推送(`ADMIN_WS_ENABLED=false`)时返回 `code != 0`、`msg` 含「实时推送未启用」,前端退回为只用 HTTP 列表,不需要重试。 ### 2.2 频道授权 Echo 订阅私有频道时自动请求 `POST /admin/broadcasting/auth`(表单字段 `socket_id`、`channel_name`,请求头带后台 token),前端只需在 Echo 配置里带上请求头。 | HTTP 状态 | 含义 | 前端处理 | | --- | --- | --- | | `200` | 返回 `{"auth": "…", "channel_data": "{\"user_id\":\"1:1793530800\"}"}`,Echo 自动完成订阅 | 无 | | `401` | 未登录或 token 已过期 | 走后台登录失效流程,停止 Echo(`echo.disconnect()`) | | `403` | 没有该列表的按钮权限、频道不存在或推送未启用 | 该页面退回为只用 HTTP 列表 | 该接口不经过 `admin.jwt` 中间件(中间件未登录时仍返回 HTTP 200,Echo 无法识别),由控制器自行校验 token 并返回上述状态码。 ### 2.3 登录过期断开 授权响应的 `channel_data` 里带有「管理员 id:JWT 过期时间」,Reverb 把它记在连接上。relay 每 5 秒检查一次,到期的会话调用 Reverb 的断开接口;Echo 会自动重连并重新授权,此时 token 已过期,授权返回 401,前端按 2.2 处理即可。同一管理员重新登录后的新会话过期时间不同,不会被误断。 ### 2.4 断线补发与重新同步 `GET /admin/realtime/replay?after_seq=1532&channels=admin.orders.sport,admin.orders.withdraw`(`admin.jwt`) ```json { "code": 0, "msg": "OK", "data": { "resync": false, "last_seq": 1540, "channels": ["admin.orders.sport", "admin.orders.withdraw"], "events": [ { "seq": 1535, "channel": "admin.orders.sport", "event": "order.updated", "...": "与推送消息相同" } ] } } ``` | 字段 | 说明 | | --- | --- | | `events` | `seq > after_seq` 的事件,只含请求且有权限的频道,按当前数据重新解析,按 `seq` 升序 | | `resync` | `true` 时 `events` 为空,前端应重新请求这些频道的列表,并把游标设为 `last_seq` | | `reason` | `resync` 的原因:`history_unavailable`(游标对应的事件已被清理,默认保留 72 小时)、`too_many_events`(积压超过 `ADMIN_WS_REPLAY_LIMIT`,默认 1000)、`invalid_cursor` | | `channels` | 实际处理的频道(无权限的已剔除) | 补发与实时推送可能重叠,前端用 `seq + channel` 去重。 ## 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` 两个频道(两张列表都显示它),两条消息的 `seq` 相同、`channel` 不同。 `GET /admin/withdraw` 与 `GET /admin/withdraw/rmb` 新增了可选查询参数 `id`(精确匹配),收到事件后想单独刷新一行也可以用它;其余列表原本就支持 `id`(在线用户用 `member_id`)。 ## 4. 事件载荷 Echo 收到的事件对象(`broadcastAs` 为 `event` 字段,监听时写作 `.order.created`): ```json { "seq": 1533, "id": 2871, "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" } ``` | 字段 | 说明 | | --- | --- | | `seq` | 广播序号,全局连续递增;用于去重(`seq + channel`)和断线补发(`after_seq`)。晚提交的事件也会拿到新的、更大的 `seq` | | `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` 推送。若记录不在列表接口的口径内(例如被列表本身的联表或筛选条件排除),不推送。单条消息上限由 `REVERB_MAX_REQUEST_SIZE` 决定(默认放宽到 512KB),超过时该条广播失败、记日志,前端可经补发接口取回。 ### 4.1 示例:USDT 提现 `admin.orders.withdraw` `data` 字段与 `GET /admin/withdraw` 的 `data.data[]` 相同: ```json { "seq": 1533, "id": 2871, "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 { "seq": 1534, "id": 2872, "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 { "seq": 1590, "id": 2930, "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 分钟内活跃,不含虚拟用户)。relay 每 `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` 等汇总值推送里没有,需要时节流重新请求一次列表,或前端自行加减。 - **去重**:用 `seq + channel` 去重;同一 `channel + key` 收到多次时总以 `seq` 最大的 `data` 为准。 - **补发**:每次 `subscribed` 回调里按该频道的续传点调补发接口(规则见下);收到 `resync` 时重新请求当前页列表。 - **页面切换**:离开列表页时 `echo.leave(channel)`,进入时重新 `private(channel)`;整个后台共用一个 Echo 实例。 - **推送不可用时**:`config` 返回未启用、授权 403、或连接持续处于 `unavailable` 时退回原有的手动刷新 / 轮询,不影响页面使用。 依赖:`npm install laravel-echo pusher-js`。 补发游标的规则(参考实现已按此处理): - 只用**实时收到**的事件推进全局游标 `liveSeq`。同一连接上的消息按 `seq` 顺序到达,收到 `seq = S` 说明已订阅频道在 S 之前的事件都已收到;补发接口经 HTTP 返回,不保证与连接上的消息先后一致,不能用它推进 `liveSeq`。 - 断线时给每个已订阅频道记下续传点 `pending[channel] = liveSeq`(已有的不覆盖);新订阅的频道续传点也取当时的 `liveSeq`。 - 每个频道订阅成功后各自以自己的续传点调补发接口,成功后清除续传点。不能共用一个全局游标,否则先完成的频道会把游标推过其它频道还没补的事件。 参考实现: ```js import Echo from 'laravel-echo'; import Pusher from 'pusher-js'; export class AdminRealtime { constructor(http, token, onSessionExpired) { this.http = http; // 已带后台 token 的请求实例 this.token = token; this.onSessionExpired = onSessionExpired; this.liveSeq = 0; // 实时收到的最大 seq this.pending = new Map(); // channel => 待补发的起点 seq this.watched = new Map(); // channel => { onEvent, onResync } this.seen = new Set(); // `${seq}:${channel}` 去重 } async start() { const res = await this.http.get('/admin/realtime/config'); if (res.code !== 0) return false; // 未启用推送,保持 HTTP 列表 const c = res.data; this.allowed = new Set(c.channels); this.liveSeq = c.last_seq; this.echo = new Echo({ broadcaster: 'reverb', Pusher, key: c.key, wsHost: c.ws_host, wsPort: c.ws_port, wssPort: c.ws_port, forceTLS: c.force_tls, enabledTransports: ['ws', 'wss'], authEndpoint: API_BASE + c.auth_endpoint, auth: { headers: { Authorization: `Bearer ${this.token}` } }, }); this.echo.connector.pusher.connection.bind('state_change', ({ current }) => { if (current === 'connected') return; for (const channel of this.watched.keys()) { if (!this.pending.has(channel)) this.pending.set(channel, this.liveSeq); } }); return true; } /** onEvent(e) 处理单条事件;onResync() 重新请求列表 */ watch(channel, events, onEvent, onResync) { if (!this.echo || !this.allowed.has(channel)) return; this.watched.set(channel, { onEvent, onResync }); this.pending.set(channel, this.liveSeq); const sub = this.echo.private(channel); events.forEach((name) => sub.listen(`.${name}`, (e) => { this.liveSeq = Math.max(this.liveSeq, e.seq); this.deliver(e); })); // 首次订阅与每次重连后的重新订阅都会触发 sub.subscribed(() => this.replay(channel)); sub.error((err) => { if (err?.status === 401) { this.echo.disconnect(); this.onSessionExpired(); } }); } async replay(channel) { const after = this.pending.get(channel); if (after === undefined || !this.watched.has(channel)) return; try { const res = await this.http.get('/admin/realtime/replay', { params: { after_seq: after, channels: channel } }); if (res.code !== 0) throw new Error(res.msg); if (res.data.resync) this.watched.get(channel)?.onResync(); else res.data.events.forEach((e) => this.deliver(e)); } catch (e) { this.watched.get(channel)?.onResync(); // 补发失败时直接重新拉列表 } if (this.pending.get(channel) === after) this.pending.delete(channel); } deliver(e) { const id = `${e.seq}:${e.channel}`; if (this.seen.has(id)) return; this.seen.add(id); if (this.seen.size > 20000) this.seen = new Set([...this.seen].slice(-10000)); this.watched.get(e.channel)?.onEvent(e); } unwatch(channel) { this.watched.delete(channel); this.pending.delete(channel); this.echo?.leave(channel); } } // 体彩订单列表页 realtime.watch('admin.orders.sport', ['order.created', 'order.updated', 'order.deleted'], (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`) - **在线状态**:不依赖写入点,由 relay 定期比对在线集合,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 对应写入之后往同一张表插入一行即可,不需要新增依赖或配置,也不需要跟着换框架或接入 Reverb。`seq` 列由 relay 填写,写入方不要赋值。以 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()`。 - 在事务内插入也可以(回滚即不推送)。relay 会在 60 秒(`ADMIN_WS_GAP_SECONDS`)内补发暂未提交的事件;事务持续超过该时长的事件可能漏推,长事务建议提交后再插入。 - 插入失败必须吞掉异常,不能影响下单、支付。 ## 8. 部署 需要两个常驻进程:**Reverb** 与 **relay**,各运行一个实例。不需要队列 worker,不需要 Node。 ### 8.1 迁移与配置 ```bash php artisan migrate --force ``` 涉及 `2026_10_31_160000_create_admin_realtime_events.php`(事件表)与 `2026_10_31_210000_add_seq_to_admin_realtime_events.php`(`seq` 列)。 `.env` 新增(示例见 `example.env`): | 变量 | 说明 | | --- | --- | | `ADMIN_WS_ENABLED` | `true` 开启。未执行迁移、未启动 Reverb 与 relay 时保持 `false`,此时不登记任何事件 | | `ADMIN_WS_PUBLIC_HOST` / `ADMIN_WS_PUBLIC_PORT` / `ADMIN_WS_PUBLIC_SCHEME` | 前端 Echo 连接的公网地址,如 `admin.example.com` / `443` / `https` | | `REVERB_APP_ID` / `REVERB_APP_KEY` / `REVERB_APP_SECRET` | Reverb 应用凭据。KEY、SECRET 用 `openssl rand -hex 16` 生成;SECRET 不得下发前端 | | `REVERB_HOST` / `REVERB_PORT` / `REVERB_SCHEME` | Laravel 访问 Reverb HTTP 接口的内网地址,如 `127.0.0.1` / `8080` / `http` | | `REVERB_SERVER_HOST` / `REVERB_SERVER_PORT` | Reverb 监听地址,建议 `127.0.0.1` / `8080`,由 Nginx 对外 | | `REVERB_ALLOWED_ORIGINS` | 允许连接的后台前端域名,逗号分隔、不含协议,如 `admin.example.com` | | `REVERB_MAX_REQUEST_SIZE` | 单条广播请求上限,默认 `524288`(512KB) | | `ADMIN_WS_POLL_MS` | relay 空闲时的轮询间隔,默认 `1000` | | `ADMIN_WS_GAP_SECONDS` | 晚提交事件的补发窗口,默认 `60` | | `ADMIN_WS_REPLAY_LIMIT` | 补发接口单次最多事件数,超过返回 `resync`,默认 `1000` | | `ADMIN_WS_ONLINE_SCAN_SECONDS` | 在线状态扫描间隔,默认 `15` | | `ADMIN_WS_RETENTION_HOURS` | 事件表保留时长(也是补发能回溯的最长时间),默认 `72` | 改完执行 `php artisan config:cache`。调度器已加入每小时 `admin-realtime:prune` 清理过期事件,沿用现有 `schedule:run` 定时任务即可。 ### 8.2 进程守护(Supervisor) ```ini ; /etc/supervisor/conf.d/bot28-realtime.conf [program:bot28-reverb] directory=/www/wwwroot/bot-28 command=php artisan reverb:start autostart=true autorestart=true stopsignal=TERM stopwaitsecs=10 user=www minfds=10000 redirect_stderr=true stdout_logfile=/www/wwwroot/bot-28/storage/logs/realtime/reverb.log [program:bot28-admin-relay] directory=/www/wwwroot/bot-28 command=php artisan admin-realtime:relay autostart=true autorestart=true stopsignal=TERM stopwaitsecs=15 user=www redirect_stderr=true stdout_logfile=/www/wwwroot/bot-28/storage/logs/realtime/relay.log ``` - relay 默认运行 1 小时后正常退出(`--max-seconds`),由 Supervisor 拉起新进程,避免长驻内存增长;收到 `SIGTERM` 会处理完当前批次再退出。 - relay 用缓存锁保证全局只有一个实例;被强杀后新进程最多等待约 35 秒拿到锁。多台服务器部署时缓存必须是共享的(Redis),且只在一台上运行 relay。 - 发布新代码后执行 `php artisan reverb:restart` 并重启 relay(`supervisorctl restart bot28-admin-relay`)。 - Reverb 连接数多时需调高文件句柄(`minfds`);后台管理员连接数少,默认即可。详见 [Reverb 进程管理](https://laravel.com/docs/13.x/reverb#process-management)。 ### 8.3 Nginx 后台域名的 server 块内,把 Pusher 协议路径反代到 Reverb: ```nginx location /app { proxy_pass http://127.0.0.1:8080; 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; } ``` `/apps/...` 是 Reverb 的服务端 HTTP 接口(广播、断开连接),只供本机 Laravel 调用,**不要**对外反代。 ### 8.4 发布顺序 1. 发布代码,执行迁移,配置 `.env`,`ADMIN_WS_ENABLED` 先保持 `false`。 2. 启动 Reverb,配置 Nginx;`curl -s http://127.0.0.1:8080/up` 返回 200。 3. 将 `ADMIN_WS_ENABLED` 改为 `true` 并 `config:cache`,启动 relay;`storage/logs/realtime/relay.log` 出现「relay 启动」。 4. 后台页面实际下单核对推送,再通知前端上线。 关闭推送:`ADMIN_WS_ENABLED=false` 并刷新配置,业务写入不再登记事件,relay 退出(Supervisor 会反复拉起并立即退出,关闭期间可 `supervisorctl stop bot28-admin-relay`)。 从旧 Node 网关切换:停止并删除 `bot28-admin-ws` 进程与 Nginx 的 `/admin-ws`、`/admin/realtime/events` 配置,删除 `.env` 中的 `ADMIN_WS_PUBLIC_URL`、`ADMIN_WS_SECRET`、`ADMIN_WS_INTERNAL_SECRET`、`ADMIN_WS_PORT`、`ADMIN_WS_PATH`、`ADMIN_WS_LARAVEL_URL`。前端须同时改为 Echo(旧的 `/admin/realtime/ticket` 已移除)。 ### 8.5 本地开发 ```bash php artisan migrate ``` ```bash php artisan reverb:start --debug ``` ```bash php artisan admin-realtime:relay ``` `.env` 中设置 `ADMIN_WS_ENABLED=true`、`REVERB_APP_KEY`/`SECRET` 任意值、`REVERB_HOST=127.0.0.1`、`REVERB_PORT=8080`、`REVERB_SCHEME=http`、`ADMIN_WS_PUBLIC_HOST=127.0.0.1`、`ADMIN_WS_PUBLIC_PORT=8080`、`ADMIN_WS_PUBLIC_SCHEME=http`。 ## 9. 验证情况 - `tests/Integration/AdminRealtimeTest.php`(隔离 SQLite):事务提交后才登记、回滚丢弃、关闭时不登记、事件表缺失不影响业务事务、模型钩子只推订单与新用户、推送行与 `/admin/withdraw` 列表行逐字段一致、删除只带主键、晚提交事件按更大的 `seq` 补发且超出窗口不补、按 `seq` 补发与频道过滤、三种 `resync`、频道授权签名与 Reverb 校验算法一致、401/403、过期会话只断开一次且 Reverb 不可达只记日志。 - 本机端到端(SQLite + `reverb:start` + `admin-realtime:relay` + Node 中的 `laravel-echo@2.1.6`/`pusher-js@8.4.0`):授权订阅成功、relay 广播实时送达且未订阅频道收不到;token 40 秒到期时 Reverb 断开连接、自动重连授权 401;Reverb 停止期间写入的事件广播失败记日志,Reverb 恢复后 Echo 自动重连、补发接口取回缺失事件并继续实时接收;relay 收到 `SIGTERM` 正常退出并释放锁。 - 在线用户扫描与各订单列表的单行解析依赖 MySQL 语法(`COLLATE` 等),未在真实数据库、真实 Nginx / wss 环境下验证;上线后请按 8.4 实际下单核对。