admin-websocket.md 32 KB

后台订单 / 用户列表实时推送(Reverb + Laravel Echo)

后台各订单列表、用户列表、在线用户列表通过 WebSocket 接收「新增 / 变更 / 删除 / 上下线」事件。事件里带的 data 就是对应列表接口返回的那一行,前端可以直接插入或替换表格行,不用再请求一次列表。

2026-10-31:随 Laravel 13 升级,推送通道由自研 Node 网关(realtime/admin-gateway.js,原生 WebSocket + 一次性票据)改为 Laravel 官方的 Reverb 与 Laravel Echo。事件表、列表行解析、频道与载荷的 data 均不变;前端需要按第 2、5 节改用 Echo;可直接交给后台前端的独立说明见后台实时推送-前端接入。

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/<key>(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>,只要求后台登录态。

{
  "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)

{
  "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):

{
  "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[] 相同:

{
  "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[] 相同:

{
  "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"
}

下线:

{ "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。
  • 每个频道订阅成功后各自以自己的续传点调补发接口,成功后清除续传点。不能共用一个全局游标,否则先完成的频道会把游标推过其它频道还没补的事件。

参考实现:

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 的登录、活跃都能覆盖。

以后新增不经模型的批量写入时,在写入后调用:

\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 模型事件为例:

// 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 迁移与配置

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)

; /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 进程管理。

8.3 Nginx

后台域名的 server 块内,把 Pusher 协议路径反代到 Reverb:

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 本地开发

php artisan migrate
php artisan reverb:start --debug
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 实际下单核对。