admin-websocket.md 27 KB

后台订单 / 用户列表 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>,只要求后台登录态,不需要额外菜单权限。

{
  "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=<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. 事件载荷

通用结构:

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

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

{
  "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 秒一次)重新请求。

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;整个后台共用一条连接即可。
  • 推送不可用时:换票失败或连接持续失败时退回原有的手动刷新 / 轮询,不影响页面使用。

参考实现:

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

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

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

// 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)

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;
}

8.2 推送网关(Node ≥ 18)

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 未开启)。

8.3 发布顺序

  1. 发布代码,执行迁移,配置 .env,ADMIN_WS_ENABLED 先保持 false。
  2. 安装并启动网关,配置 Nginx。
  3. 将 ADMIN_WS_ENABLED 改为 true 并刷新配置缓存,/health 正常后通知前端上线。

关闭推送:ADMIN_WS_ENABLED=false 并刷新配置即可,业务写入不再登记事件,网关可保留运行。

8.4 本地开发

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 为本地后台地址。

9. 验证情况

  • tests/Integration/AdminRealtimeTest.php(隔离 SQLite):事务提交后才登记、回滚丢弃、关闭时不登记、事件表缺失不影响业务事务、模型钩子只推订单与新用户、推送行与 /admin/withdraw 列表行逐字段一致、删除只带主键、按 gaps 补拉、票据签名与 Node 网关算法互通。
  • 网关用模拟内部接口做了端到端验证:无效 / 重复票据 4401、无权限频道被拒、未提交事件补拉后送达、按 last_event_id 补发、历史不足时 resync、后台登录过期断开、健康检查。
  • 在线用户扫描与各订单列表的单行解析依赖 MySQL 语法(COLLATE 等),未在真实数据库、真实 Nginx / wss 环境下验证;上线后请按 8.3 用 /health 与后台页面实际下单核对。