后台各订单列表、用户列表、在线用户列表通过 WebSocket 接收「新增 / 变更 / 删除 / 上下线」事件。事件里带的 data 就是对应列表接口返回的那一行,前端可以直接插入或替换表格行,不用再请求一次列表。
2026-10-31:随 Laravel 13 升级,推送通道由自研 Node 网关(realtime/admin-gateway.js,原生 WebSocket + 一次性票据)改为 Laravel 官方的 Reverb 与 Laravel Echo。事件表、列表行解析、频道与载荷的 data 均不变;前端需要按第 2、5 节改用 Echo;可直接交给后台前端的独立说明见后台实时推送-前端接入。
业务写入(下单、支付回调、结算、审核、注册……)
│ 事务提交后登记一条「表 + 主键 + 动作」
▼
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 按广播先后分配,补发接口不会漏 |
选型说明:
ShouldBroadcastNow 在 relay 进程内同步发送,不需要队列 worker;Reverb 不可用时广播失败只记日志,前端重连后凭 seq 补发。seq、补发接口和 resync 仍由应用实现。DB::afterCommit),事务回滚就不推送;登记、解析、广播任何一步失败都只记 storage/logs/realtime/ 日志,不影响下单、充值等主业务。GET /admin/realtime/config,取得 Echo 连接参数、可订阅频道、当前 last_seq 与登录过期时间。broadcaster: 'reverb'),授权地址 /admin/broadcasting/auth,请求头带后台 token。echo.private(channel).listen('.order.created', …) 等;频道名不带 private- 前缀,事件名前面加 .。GET /admin/realtime/replay?after_seq=<收到过的最大 seq>&channels=<频道> 追赶期间的事件;返回 resync: true 时重新请求一次列表。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 列表,不需要重试。
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 并返回上述状态码。
授权响应的 channel_data 里带有「管理员 id:JWT 过期时间」,Reverb 把它记在连接上。relay 每 5 秒检查一次,到期的会话调用 Reverb 的断开接口;Echo 会自动重连并重新授权,此时 token 已过期,授权返回 401,前端按 2.2 处理即可。同一管理员重新登录后的新会话过期时间不同,不会被误断。
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 去重。
| 频道 | 对应列表接口(页面) | 行主键 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)。
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),超过时该条广播失败、记日志,前端可经补发接口取回。
admin.orders.withdrawdata 字段与 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"
}
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 秒一次)重新请求。
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 |
字段含义与各列表接口文档一致,这里不重复。
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());
bot-28 内以下写入会产生事件:
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)RechargeService、RechargeBonusService)PaymentOrderService)PaymentOrder、Withdraw 控制器)SecretService)以后新增不经模型的批量写入时,在写入后调用:
\App\Services\AdminRealtime\AdminRealtime::record('payment_orders', $ids); // 默认 updated
\App\Services\AdminRealtime\AdminRealtime::record('withdraws', $id, 'created');
它在事务内会等提交后再写,回滚则丢弃,失败只记日志。
会员前台 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。save() / create() 时触发,Db::name()->update() 这类批量写入需要在写入后手动调用 push()。ADMIN_WS_GAP_SECONDS)内补发暂未提交的事件;事务持续超过该时长的事件可能漏推,长事务建议提交后再插入。需要两个常驻进程:Reverb 与 relay,各运行一个实例。不需要队列 worker,不需要 Node。
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 定时任务即可。
; /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
--max-seconds),由 Supervisor 拉起新进程,避免长驻内存增长;收到 SIGTERM 会处理完当前批次再退出。php artisan reverb:restart 并重启 relay(supervisorctl restart bot28-admin-relay)。minfds);后台管理员连接数少,默认即可。详见 Reverb 进程管理。后台域名的 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 调用,不要对外反代。
.env,ADMIN_WS_ENABLED 先保持 false。curl -s http://127.0.0.1:8080/up 返回 200。ADMIN_WS_ENABLED 改为 true 并 config:cache,启动 relay;storage/logs/realtime/relay.log 出现「relay 启动」。关闭推送: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 已移除)。
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。
tests/Integration/AdminRealtimeTest.php(隔离 SQLite):事务提交后才登记、回滚丢弃、关闭时不登记、事件表缺失不影响业务事务、模型钩子只推订单与新用户、推送行与 /admin/withdraw 列表行逐字段一致、删除只带主键、晚提交事件按更大的 seq 补发且超出窗口不补、按 seq 补发与频道过滤、三种 resync、频道授权签名与 Reverb 校验算法一致、401/403、过期会话只断开一次且 Reverb 不可达只记日志。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 正常退出并释放锁。COLLATE 等),未在真实数据库、真实 Nginx / wss 环境下验证;上线后请按 8.4 实际下单核对。