后台各订单列表、用户列表、在线用户列表通过 WebSocket 接收「新增 / 变更 / 删除 / 上下线」事件。事件里带的 data 就是对应列表接口返回的那一行,前端可以直接插入或替换表格行,不用再请求一次列表。
业务写入(下单、支付回调、结算、审核、注册……)
│ 事务提交后登记一条「表 + 主键 + 动作」
▼
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 发布订阅或第三方服务。ws 的 Node 小进程就够了。服务器已有 Node(node/ 目录的 TRON 转账脚本在用)。id 参数调用各列表接口自己的查询与格式化,所以推送行和列表行一致,以后列表改了字段,推送跟着变。DB::afterCommit),事务回滚就不推送;登记、解析、推送任何一步失败都只记 storage/logs/realtime/ 日志,不影响下单、充值等主业务。GET /admin/realtime/ticket 换取一次性连接票据(有效期 60 秒,只能使用一次)。data.url + '?ticket=' + data.ticket。welcome 后发送 subscribe,订阅当前页面需要的频道。event 消息:按 channel 找到列表,按 action 插入、替换或删除行。id(last_event_id);收到 resync 时重新请求一次列表。GET /admin/realtime/ticket,请求头 Authorization: Bearer <后台 token>,只要求后台登录态,不需要额外菜单权限。
{
"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 列表,不需要重试。
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"}。
前端发送:
| 消息 | 说明 |
|---|---|
{"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"} |
前端发了无法识别的消息 |
| 频道 | 对应列表接口(页面) | 行主键 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)。
通用结构:
{
"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 推送。若记录不在列表接口的口径内(例如被列表本身的联表或筛选条件排除),不推送。
admin.orders.withdrawdata 字段与 GET /admin/withdraw 的 data.data[] 相同:
{
"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"
}
admin.users.online上线,data 与 GET /admin/online/users 的 data.data[] 相同:
{
"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"
}
下线:
{ "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 秒一次)重新请求。
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 等汇总值推送里没有,需要时节流重新请求一次列表,或前端自行加减。channel + id 去重;同一 channel + key 收到多次时总以最后到达的 data 为准(data 总是最新状态)。last_event_id:保存收到过的最大事件 id;重连后在 subscribe 里带上它,网关会补发期间的事件。resync、或换票失败后重新连上时:重新请求一次当前页列表,再继续接收事件。网关重启、断线时间过长(默认保留最近 5000 条事件)都会触发。unsubscribe,进入时 subscribe;整个后台共用一条连接即可。参考实现:
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());
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 对应写入之后往同一张表插入一行即可,不需要新增依赖或配置。以 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()。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 上只允许本机访问:
location = /admin/realtime/events {
allow 127.0.0.1;
deny all;
try_files $uri /index.php?$query_string;
}
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 |
用于断线补发的最近事件条数 |
进程守护(二选一,只运行一个网关进程):
; /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
pm2 start realtime/admin-gateway.js --name bot28-admin-ws && pm2 save
Nginx 反代(后台域名的 server 块内):
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;
}
健康检查(在服务器本机执行):
curl -s http://127.0.0.1:6002/health
返回 ok: true、cursor 随业务增长即正常;last_error 为拉取内部接口的最近错误(如 HTTP 403 表示内部密钥不一致,code=… 实时推送未启用 表示后台 ADMIN_WS_ENABLED 未开启)。
.env,ADMIN_WS_ENABLED 先保持 false。ADMIN_WS_ENABLED 改为 true 并刷新配置缓存,/health 正常后通知前端上线。关闭推送:ADMIN_WS_ENABLED=false 并刷新配置即可,业务写入不再登记事件,网关可保留运行。
php artisan migrate
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 为本地后台地址。
tests/Integration/AdminRealtimeTest.php(隔离 SQLite):事务提交后才登记、回滚丢弃、关闭时不登记、事件表缺失不影响业务事务、模型钩子只推订单与新用户、推送行与 /admin/withdraw 列表行逐字段一致、删除只带主键、按 gaps 补拉、票据签名与 Node 网关算法互通。last_event_id 补发、历史不足时 resync、后台登录过期断开、健康检查。COLLATE 等),未在真实数据库、真实 Nginx / wss 环境下验证;上线后请按 8.3 用 /health 与后台页面实际下单核对。