|
|
@@ -0,0 +1,517 @@
|
|
|
+# 后台订单 / 用户列表 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>`,只要求后台登录态,不需要额外菜单权限。
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "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. 事件载荷
|
|
|
+
|
|
|
+通用结构:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "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[]` 相同:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "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[]` 相同:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "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"
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+下线:
|
|
|
+
|
|
|
+```json
|
|
|
+{ "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`;整个后台共用一条连接即可。
|
|
|
+- **推送不可用时**:换票失败或连接持续失败时退回原有的手动刷新 / 轮询,不影响页面使用。
|
|
|
+
|
|
|
+参考实现:
|
|
|
+
|
|
|
+```js
|
|
|
+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 的登录、活跃都能覆盖。
|
|
|
+
|
|
|
+以后新增不经模型的批量写入时,在写入后调用:
|
|
|
+
|
|
|
+```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 对应写入之后往同一张表插入一行即可,不需要新增依赖或配置。以 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()`。
|
|
|
+- 在事务内插入也可以(回滚即不推送)。网关会把暂未提交的事件 id 记下,在 60 秒内补拉;事务持续超过 60 秒的事件可能漏推,长事务建议提交后再插入。
|
|
|
+- 插入失败必须吞掉异常,不能影响下单、支付。
|
|
|
+
|
|
|
+## 8. 部署
|
|
|
+
|
|
|
+### 8.1 后台(Laravel)
|
|
|
+
|
|
|
+```bash
|
|
|
+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 上只允许本机访问:
|
|
|
+
|
|
|
+```nginx
|
|
|
+location = /admin/realtime/events {
|
|
|
+ allow 127.0.0.1;
|
|
|
+ deny all;
|
|
|
+ try_files $uri /index.php?$query_string;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 8.2 推送网关(Node ≥ 18)
|
|
|
+
|
|
|
+```bash
|
|
|
+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` | 用于断线补发的最近事件条数 |
|
|
|
+
|
|
|
+进程守护(二选一,只运行**一个**网关进程):
|
|
|
+
|
|
|
+```ini
|
|
|
+; /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
|
|
|
+```
|
|
|
+
|
|
|
+```bash
|
|
|
+pm2 start realtime/admin-gateway.js --name bot28-admin-ws && pm2 save
|
|
|
+```
|
|
|
+
|
|
|
+Nginx 反代(后台域名的 server 块内):
|
|
|
+
|
|
|
+```nginx
|
|
|
+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;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+健康检查(在服务器本机执行):
|
|
|
+
|
|
|
+```bash
|
|
|
+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 本地开发
|
|
|
+
|
|
|
+```bash
|
|
|
+php artisan migrate
|
|
|
+```
|
|
|
+
|
|
|
+```bash
|
|
|
+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` 与后台页面实际下单核对。
|