老虎机开发方案
独立新项目。先看 完整流程:上分进游戏、spin、水位 RTP、大局表、正常转、断线重连、断开下分。商家 /launch 只取地址,上分在连上 CF 时由大厅在 connect proxy 里调商家全额拉;大厅、游戏服都无状态、互不调用,只共享 DB / Redis,只有 Centrifugo 持有连接;掉线靠轮询 presence;局内走 user,全款广播(奖池等)走游戏频道 slot:{gameId},按 code 分类型。
| 序号 | 章节 | 这一节要钉死什么 | 状态 |
|---|---|---|---|
| 0 | 完整流程 | 上分进游戏 → spin → 水位 RTP → 大局表 → 正常转 → 断线重连 → 断开下分 | 按已定口径写清 |
| 1 | 游戏通信 | 游戏服无状态,走 CF publish proxy;订阅由 connect proxy 服务端下发;code 成对:1011↔-1011,1010↔-1010 | 口径已定 |
| 2 | 上下分 | 商家调大厅 /launch 只取地址不动钱;玩家连上 CF 时大厅在 connect proxy 里调商家按余额全额扣走、加游戏分;第一次和再进同一条 | 口径已定 |
| 3 | RTP(水位与返水) | 表按「风格 × RTP 档」组:风格(温和/刺激)运营选,RTP 档(低/中/高)水位自动切;抽 1 条不筛 | 口径已定 |
| 4 | 断线重连 | 大厅不连 CF;定时任务轮询 presence,连续 3 秒不在才下空闲分;未完连消再进后 recover 继续 | 与下分对齐 |
目录
0. 完整流程
下面按一条线写:上分 → 进游戏 → spin → 预算 RTP → 存大局表 → 正常转 → 断线重连 → 断开下分。细则仍在后面各章,这里只把已经钉死的顺序说清楚。
0.0 时序图
六条泳道:客户端、商家、大厅、Centrifugo、游戏服、DB / Redis。实线是请求,虚线是回包 / 推送;红色条是分支 / 拒绝路径,灰色条是补充说明。每张图都是一条完整链路,字段名与 5. 数据表、code 与 1.3.2 事件 code 一一对应。第 2、3、4 章各自另有一张按判断分支画的流程图(F / G / H)。图内嵌在本页,由 老虎机流程图.gen.py 生成并写回本文件,改流程改脚本重跑,不手改 SVG。
A. 取地址 + 连上即上分
对应 0.1、0.2。要点:商家调 /launch 只拿 gameUrl,不动钱;玩家连上时 connect proxy 里大厅按 session 状态分四路(credited 挤号放行 / cashing_out 拒 retry / pending 用原 transfer.id 补问 / 无或 cashed_out 新上分);新上分 = 事务一插 pending → 调商家全额扣 → 事务二 wallet+ 补 credited;订阅由服务端下发;连上先 1001。
B. spin(1011 → -1011)
对应 0.3、3.x。要点:先查 req_id 幂等重推、再查 active 拒局、再校配置 / 余额;先校 session 是 credited;水位只决定 table_id;一次抽完整大局(含连消、免费);round + round_step × N + wallet + wallet_log + jackpot_pool 一个事务;水位在事务外 INCRBY;只推 step 0。同 reqId 打到已转完的局回 -1011 settled 带余额,不重推盘面。
C. 正常转(1010 → -1010)
对应 0.4。要点:游戏服只从 round_step 取,不重算;session 非 credited(已下分 / 正在下分)、旧步 / 越步 / 已入账一律忽略;本步 step_win 入账(201 / 202)与揭示下一步同一事务;末步把 round 置 2、累 player_game;-1010 末步不带盘面只带余额,settled: true。
D. 断线下分
对应 0.5、0.6、4.1、4.3。要点:大厅不连 CF,每秒 presence 对比 session 打 missing_since;满 3 秒先条件更新 cashing_out 关门、再 disconnect、再由大厅自己在 wallet 行锁内扣光建 transfer(direction=2)、再调商家;不经过游戏服;商家超时用同一 transfer.id 重试;未完局的未 ack 赢分留在 round_step,超时才由任务结算。
E. 下分后再进
对应 0.7、4.2。要点:没有单独接口,就是再连一次走 A 的连接部分;session 已 cashed_out → 新上分;amount=0 只有有未完局才放行;-1001 带 revealed_step,客户端从那一步接着播、接着 1010,赢分打进这次的 wallet。
0.1 取游戏地址(商家调大厅,不动钱)
- 玩家在商家页面点这款游戏。商家调大厅
POST /launch:merchant_uid、gameId、req_id,可带 nickname / avatar / vip_level / lang。不带金额,这一步商家不扣款。 - 大厅 UPSERT player(按 ns + merchant_uid 拿内部 uid),签 JWT(
sub=uid、gameId、jti、exp24 小时),把带 token 的gameUrl回给商家。不插 session、不动 wallet、不调商家扣款。 - 商家把
gameUrl交给玩家。同一个人重复取,每次给新 token;旧 token 到期前仍能连,谁先连谁算(见 0.2 挤号)。
第一次进、下分后再进、换设备进,都是这一条。没有「第一次」和「再进」两套。取地址失败(商家签名不对、游戏下架、玩家冻结)直接回错,玩家进不了。
0.2 连上即上分(connect proxy 里完成)
钱只在玩家真的连上时才动,由大厅在 connect proxy 里去商家拉。上分方向只有一种:大厅调商家扣款,商家按该用户在商家侧的余额全部扣走并返回金额。商家永远不主动往我们这里推钱。
- 玩家打开
gameUrl,页面从 URL 取出 JWT,连 Centrifugo。 - Centrifugo connect proxy 打到大厅,带 JWT claims 和这条连接的
clientid。大厅验签、拿 uid / gameId;取 Redis 锁lock:connect:{ns}:{uid}:{gameId},拿不到 → 拒绝(retry),客户端 1 秒后再连。 - 查这人这款最新一行 session,按状态分四路:
- credited(3 秒内重连、或换设备):不动钱。挤号:API publish -1002 到 user 频道,CF
disconnect该 uid 但whitelist这条新 client;session.jti 覆盖成新 JWT 的 jti、missing_since清空、connected_at=now。放行。 - cashing_out(正在下分):拒绝(
retry)。客户端 1 秒后重连,下分完就落到下一路。 - pending(上次调商家超时没结果):用原
transfer.id再调商家扣款。商家按 req_id 幂等,已扣过就返回同一结果。拿到金额补完 credited,放行。 - cashed_out / failed / 没有:新上分。事务一:INSERT session(pending, jti) + INSERT transfer(101, status=0)。调商家扣款 {merchant_uid, gameId, req_id=transfer.id},商家按余额全部扣走返回 amount。事务二:UPDATE transfer(status=1, amount, merchant_tx_id) · UPDATE wallet += amount · INSERT wallet_log(101) · UPDATE session(credited, amount_in, connected_at=now) · player_game.sessions+1。放行。
- credited(3 秒内重连、或换设备):不动钱。挤号:API publish -1002 到 user 频道,CF
- 放行响应带
channels:slot:{gameId}:user:{uid}和slot:{gameId},由服务端替他订。客户端不自己订,不可能漏订游戏频道。 - 连上后客户端先在 user 频道 publish 一条 1001(recover),游戏服回 -1001:带当前余额、当前奖池;有未完局再带当前已揭示步接着播。频道不开 history,玩家和游戏服之间没有 HTTP。
- 玩家在 user 频道 publish 1011 / 1010。Centrifugo publish proxy 把这条打到任意一台游戏服(无状态,不订频道、不分配、不退订)。游戏服算完用 Centrifugo 服务端 API publish -1011 / -1010 到 user、-1101 到游戏频道。
- 大厅不连 CF。一个带锁的定时任务每秒调一次 Centrifugo
presence(一款一次),拿到在线 uid 集合,和库里 credited 且已连过的单子对比。
拒绝只有三种,客户端按 reason 处理:retry(锁没拿到 / cashing_out / 商家超时)自动 1 秒后重连;no_balance(商家返回 0 且没有未完局)提示回商家充值;denied(JWT 过期 / 玩家冻结 / 游戏下架 / 商家明确拒绝)回商家重新取地址。商家返回 0 但有未完局:照样 credited、加 0 分,放行后只能 ack 播完不能 spin。
为什么钱放在 connect proxy 里:拿了地址不连、连不上,钱都没动,不需要「发地址 30 秒不连退回」这条任务;商家只对接三件事——取地址、被扣款、被加款;也不存在「已扣款但地址没发出去」要冲正的情况——商家扣成功后我们这边失败,session 留 pending,下次连上用同一 transfer.id 再问一次就接上了。代价是 connect proxy 里多一次商家调用:Centrifugo proxy_connect_timeout 要配到 5 秒(默认 1 秒),商家调用超时 3 秒。
0.3 点旋转:spin、预算 RTP、存大局表
- 玩家在 user 频道发 1011(req_id + 下注)。
- 先查 session:最新一行不是 credited(正在下分 / 已下分)→ 忽略,不回。再查大局表:同一 uid + gameId + reqId 已有这行:不再扣注、不重抽。这行还在进行中 → 推当前已揭示步(step=0 用 -1011,之后用 -1010);这行已转完 / 超时结算 → 回 -1011
reason: settled带当前余额,不重推盘面(否则客户端播完再 1010 会被当没有进行中局忽略,卡死)。 - 表里有未转完的局、但 reqId 不是这一次:回 -1011 拒局,不扣注,不抽奖。冻着的局必须先播完。
- 既没有同 reqId 的行、也没有进行中的局:游戏余额不够或维护,同样拒局。否则才扣注、抽奖。
- 读一下这款游戏的水位(不加锁,Redis 或库快照)。水位 = 到目前为止「按目标 RTP 该吐的钱」−「实际吐的钱」,正数欠玩家、负数玩家赢多了。
- 按水位选表:先取运营选的风格(温和 / 刺激)那一组,再看水位:欠玩家超过阈值 → 高表(如 98%);玩家赢多超过阈值 → 低表(如 94%);区间内 → 中表(如 96%)。同一组三张表符号、玩法、连消规则、命中率一样,只有大奖权重不同。
- 从选中的那张表抽 1 条完整大局:有连消的带齐全部小局,没有连消的只有 step=0。算出总奖额。不筛、不重抽。
- 扣注、写流水、写大局表、改奖池在同一个事务里提交(记下用的 tableId 和当时水位;
jackpot_pool.amount += bet × contribution − jackpot_win)。中途挂了整体回滚:注没扣、表里没行,玩家用原 reqId 重发就当新局。禁止扣了注却没写表。 - 事务外:
水位 += 下注 × 目标RTP − 总奖额,一条 INCRBY。不锁、不 CAS。水位只是选表信号,晚一秒、差几块不影响钱。 - 事务提交后才推当前这一小局:-1011
step=0。不带后面几消,不带本局总赢分。
同一 uid + gameId 大局表里最多一行未转完。有这行时:同一 reqId 再 1011 就从表取出;换新 reqId 则拒局,只能继续 ack。转完改状态,不删;转完之后同 reqId 再来只回 settled。
0.4 正常转(含连消)
- 前端收到 -1011 再停轮、播动画。这条 -1011 带这一小局的盘面和
stepWin,不带后面几消,不带本局总赢分。 - 播完发 1010 ack(round_id + 当前 step)。ack 只能从大局表取。先看 session:不是 credited(正在下分 / 已下分)→ 忽略,不加分,钱不会打进已关的钱包。没有进行中的大局:忽略,不加分。发来的 step 比当前
revealedStep小(旧 ack 重发):忽略,不加第二遍分。只有step == revealedStep才把这一小局stepWin加进余额一次。 - 还有下一步(当前盘面
hasNext=true):仍从大局表取下一小局,再推一条 -1010。这是下一屏,继续播、再 ack。 - 没有下一步(当前盘面
hasNext=false):这已经是最后一屏,不要再播。1010 后加上这一小局的分,再回一条 -1010,带入账后余额和settled: true,不当新盘面。 - 转完了,大局 status 改为「转完」,不删。大局、小局永久留着,回放和对账用。这人这款没有「进行中」状态的大局了,才能再发 1011。
客户端不本地加减余额。扣注看 -1011(成功有盘面,拒局有 reason);每一小局入账看这次 1010 之后那条 -1010 的余额。
写入发生在 1011 开奖;ack / 下一步 -1010 / recover 都只读这张表;转完改状态不删,一人一款最多一条进行中。断线不动。
0.5 断线不到 3 秒:当没断过
- 玩家这条游戏 CF 断了(杀进程、闪断、弱网)。Centrifugo 把这条连接从频道里移掉,presence 里没他了。
- 轮询任务发现库里该在的 uid 不在 presence:单子写
missing_since=now。大局表不动,空闲分先不下。 - 3 秒内用原
gameUrl上的 JWT 再连上,connect proxy 又替他订回游戏频道,presence 里又有他:下一轮把missing_since清空。 - 连上先发 1001,-1001 从表里取当前步继续播。不重抽、不重新上分、不调商家。
判定只看「此刻 presence 里有没有这个 uid」。后进挤先进时新连接已经在,旧连接怎么断都不影响,不会把还在玩的人下分。
0.6 断开超过 3 秒:下空闲分
missing_since距今 ≥ 3 秒,且本轮 presence 里仍没有这个 uid。- 先关门:
UPDATE 单子 SET status='cashing_out' WHERE id=? AND status='credited'。影响 0 行 → 别人已在处理,退出。此后 connect proxy 见 cashing_out 一律拒绝。 - CF
disconnect该 uid。人若刚好在这几十毫秒里重连上了,也被踢掉;不会有人在下分中途挤进来 ack。 - 再搬钱:大厅自己在一个事务里
SELECT wallet FOR UPDATE、扣光空闲游戏分(还能拿来下注的余额)、写 wallet_log、插 transfer。不经过游戏服。和 1010 入账靠同一行的行锁互斥:在途的那条 1010 要么先入账再被一起扣走,要么排在后面读到 session 已是 cashing_out 直接忽略。然后回调商家加回真钱。 - 正在播的连消不停、不结算、不重抽。大局表这一行留着。已经 ack 过的小局赢分已经在游戏余额里,会随空闲分下走;还没 ack 的小局赢分不算进这次下分。
- 空闲分为 0(钱都冻在未完连消里)也走同一串,只是不调商家加 0。
- 单子改 cashed_out。人回来再连就是一次新上分(connect proxy 见 cashed_out → 调商家)。游戏服全程不知道这件事:无状态,没有退订,也没有内部接口。
为什么要先关门:下分是一串动作,有几十到几百毫秒。若中途人重连上(单子还是 credited 就放行)、发 1010 入账,再被扣空闲分划走,人还连着却在一个已下分的会话里继续 ack,钱卡在游戏余额里没人管。cashing_out 把「放不放人进来」和「扣分」看成同一个状态。
下分只认这一条:库里该在、presence 连续 3 秒说不在。状态全在单子表(connected_at / missing_since),大厅哪台跑任务都一样,重启不丢。presence 调不通的那一轮跳过,不写 missing,不能把「问不到」当「不在」。游戏服不报断开、不调商家。
0.7 下分后再进
没有单独流程。gameUrl 没过期就直接再连(客户端被拒后自动重连、或玩家再点进来);过期了回商家重新取地址(0.1)。连上走 0.2:session 已是 cashed_out → 新上分 → 大厅调商家全额扣 → credited → 放行 → 1001 → -1001 带当前已揭示步(step=0 同 -1011,之后同 -1010)→ 接着 1010。每步 ack 只加这一小局的 stepWin,打进这次上分后的 wallet;下一步仍从表里取;全部 ack 完大局改转完。
商家余额 0 且有未完局:放行加 0 分,只能 ack 播完。没有未完局:拒绝 no_balance。已 credited 还没下分时再连:不再扣一笔、不叠加游戏分,挤旧连接。
| 场景 | 商家真钱 | 游戏分 | 大局表 |
|---|---|---|---|
| 取地址 | 不动 | 不动 | 不动 |
| 连上 CF(第一次 / 再进) | 大厅在 connect proxy 里调商家,按该用户余额全部扣走 | 按商家返回的金额加(0 也行) | 不动;有未完局 -1001 带出来接着播 |
| spin 正常转完 | 不动 | 扣注;每步 ack 把该小局 stepWin 加上 | 开奖写入;转完改状态 |
| 断线 < 3 秒 | 不动 | 不动 | 不动,接着播 |
| 断线 ≥ 3 秒 | 空闲分加回商家 | 空闲分扣走 | 行留着,不结算 |
| 下分后再进 | 同「连上 CF」:再连一次,大厅再拉一次(余额 0 且有未完局也放行) | 按返回金额重新加 | recover 取出当前小局继续,转完改状态 |
1. 游戏通信
玩家和大厅连 Centrifugo;游戏服不连,是无状态 HTTP 服务,由 Centrifugo publish proxy 调,结果用 Centrifugo 服务端 API publish 回频道。玩家不打大厅任何 HTTP:上分在 connect proxy 里由大厅完成,下分由大厅轮询 presence 完成。局内走 slot:{gameId}:user:{uid};奖池走 slot:{gameId}。同一套信封。大厅和游戏服之间没有任何调用,只共享 MySQL / Redis;wallet 谁改都走同一行的行锁 + 同事务 wallet_log。
已定:商家调 /launch 只取地址;上分在玩家连上 CF 时由大厅在 connect proxy 里调商家按余额全部扣走。第一次和再进同一条。游戏服不调商家、不订频道。整局一次抽完落库,扣注与写表同一事务。不自研 WebSocket。
1.1 角色
| 角色 | 干什么 | 不干什么 |
|---|---|---|
| 客户端 | 拿商家给的 gameUrl 连 CF(订阅由服务端下发;上分在连上时自动完成);user 上发 1001/1011/1010、收 -1001/-1011/-1010;游戏频道上收 -1101。连上先发 1001 | 不算奖、不自己订频道、不能发负数 |
| 大厅 | 无状态 HTTP:/launch 取地址(商家调,不动钱)、connect proxy(调商家上分 + 放行 + 下发订阅 + 挤号 -1002/disconnect);加一个带锁定时任务:轮询 presence、3 秒下分(credited → cashing_out → disconnect → 行锁内扣光 wallet → 商家加款 → cashed_out)、未完局超时、pending 对账 |
不连 CF、不订任何频道、不抽奖、不推盘面、不算 RTP;不调游戏服;商家扣不成不放连接 |
| 商家 | 用户真钱账户;调大厅 /launch 取地址;被大厅回调扣款(全额)/ 加款 |
不连 Centrifugo、不抽奖 |
| 游戏服 | 无状态 HTTP。被 publish proxy 调:收 1001/1011/1010 → 读表/扣注/抽奖 → 用 CF 服务端 API publish -1001/-1011/-1010 到 user、-1101 到游戏频道(节流)。1011 / 1010 先读 session,非 credited 一律忽略。没有玩家可达的 HTTP,也没有给大厅的接口。任意一台可处理任何玩家,同一 uid 串行 | 不连 CF、不订频道、不分配玩家;不报断开;不调商家;不被大厅调、不调大厅 |
| Centrifugo | 唯一持有连接状态的组件。心跳、频道转发;connect proxy 到大厅(上分 + 放行 + 服务端订阅,超时 5 秒);publish proxy 到游戏服(1011/1010);presence API 给大厅查在线 | 不认钱、不抽奖 |
1.2 连接与进厅
- 商家调大厅
POST /launch(merchant_uid、gameId、req_id)。大厅 UPSERT player、签 JWT(sub=uid、gameId、jti、exp 24h),把带 token 的gameUrl回给商家。不动钱。 - 玩家打开
gameUrl,页面从 URL 取出 JWT 连 Centrifugo。不另打接口拿 token。 - Centrifugo connect proxy 打到大厅:验 JWT,按 session 状态分路——credited 挤号放行;cashing_out 拒 retry;pending 用原 transfer.id 补问商家;cashed_out / failed / 无 → 大厅调商家按余额全额扣款、加游戏分、插 session(credited) → 放行。放行响应下发
channels(user + 游戏频道),服务端替他订。 - 连上先 publish 1001,收 -1001。之后点旋转:publish 1011。同一用户同一款游戏后进挤先进(大厅 publish -1002 再 disconnect)。
细节与失败处理见第 2 章。HMAC 在大厅与 Centrifugo;publish proxy 到游戏服带 CF 签名,游戏服信 proxy 里的 uid,不信 payload。
1.3 频道(已定)
Centrifugo 第一段 slot 是 namespace。第二段 {gameId} 是哪一款游戏。字符串大小写敏感。
| 频道 | 谁能订 | 谁能发 | 事件 / 消息 |
|---|---|---|---|
slot:{gameId}:user:{uid} |
该玩家(connect proxy 服务端订阅) | 玩家 publish 1001 / 1011 / 1010(经 publish proxy 到游戏服);游戏服 API publish -1001 / -1011 / -1010;大厅 API publish -1002 | 私人频道。不靠 history,连上先发 1001。大厅不订这条 |
slot:{gameId} |
在玩这一款的人(服务端订阅) | 仅服务端 API publish(游戏服 -1101,其它 -11xx 看谁产生) | 全款广播频道,消息按 code 分类型:-1101 奖池;预留 -11xx 给大奖播报、公告、维护。不开 history(当前奖池由 -1001 带回)。大厅不订,只用 presence API 查这条频道拿在线 uid |
玩家 token 锁死:只能 publish 到自己的 user 频道,且经 publish proxy;不能往游戏频道发;不能发负数。订阅全部由 connect proxy 服务端下发,客户端不自己 subscribe。大厅不连 CF,只调 HTTP API(presence / publish / disconnect)。心跳用 Centrifugo ping/pong,客户端不回 pong 即判死、从 presence 移除。
gameId:游戏代号,如fortune_ox。/launch必带。- 例:
slot:fortune_ox:user:12345、slot:fortune_ox。 - 没有 machine 频道,也不发 ±1201。
- 没有大厅频道给玩家。上分在 connect proxy 里由大厅调商家拉;下分由大厅定时轮询 presence,连续 3 秒不在才下。
- 两条频道都不开 history。盘面、余额、当前奖池全靠 1001 → -1001 拿;连上、重连、等不到回包,都发 1001。
1.3.1 事件信封
同一套信封。有来有回的,数字一样、正负相反:发 1011 回 -1011,发 1010 回 -1010。正数客户端发,负数服务端回。没有对应请求的推送(踢人、强制下分、奖池)只用负数。type 与成对的 code 相同,只给日志读。
{
"code": 1011,
"type": "round_spin",
"ts": 1710000000123,
"data": {}
}
{
"code": -1011,
"type": "round_spin",
"ts": 1710000000123,
"data": {}
}
1.3.2 事件 code
成对:1001 ↔ -1001,1011 ↔ -1011,1010 ↔ -1010。正数只允许客户端发。负数只允许服务端发(游戏服 -1001/-1011/-1010/-1101,大厅 -1002)。1011 不能开局也回 -1011(带 reason,不带盘面),不再另开拒局号。没有请求的推送:-1002 踢人、-1101 奖池。不设 -1003:人已经断了没人收,对账走流水。publish proxy 只放行正数 1001/1011/1010,负数一律拒。客户端丢掉自己的 1010/1011 回声。
点旋转:客户端发 1011(req_id + 下注额)。成功回 -1011 step=0;失败回 -1011 拒局。播完发 1010,有连消再回 -1010。丢了发 1001。重连推当前步:step=0 用 -1011,之后用 -1010。
| code | type | 频道 | 何时发 | data 要点 |
|---|---|---|---|---|
| 1001 | recover |
slot:{gameId}:user:{uid} |
每次连上 CF 后固定先发一条;等不到 -1011 / -1010 时也发 | 空。游戏服只信 proxy 里的 uid 和频道 |
| -1001 | recover |
slot:{gameId}:user:{uid} |
回 1001。只读,不抽、不改表、不动水位 | 必带 balance、jackpot(当前池)、betOptions。有未完局再带 round:结构同 -1011 / -1010 的 data(roundId、step、盘面、stepWin、hasNext)外加 reqId(取自 round 行);没有则 round: null |
| 1011 | round_spin |
slot:{gameId}:user:{uid} |
客户端点旋转 | reqId 下注额。禁止带盘面 |
| -1011 | round_spin |
slot:{gameId}:user:{uid} |
回 1011。成功:step=0 盘面。失败:拒局。重连时若当前是 step=0 也推这条 | 成功:roundId step 盘面 stepWin hasNext。失败:reqId reason,不带盘面;round_in_progress 时另带未完局的 roundId revealedStep;settled(同 reqId 打到已转完的局)时另带 balance |
| 1010 | round_ack |
slot:{gameId}:user:{uid} |
客户端播完当前步之后发。游戏服只从大局表取 | roundId step。禁止带盘面、禁止带赢分 |
| -1010 | round_ack |
slot:{gameId}:user:{uid} |
回 1010。有下一步:下一屏盘面。末步:入账后余额,不当新盘面播。重连时若当前 step≥1 也推这条 | 盘面 stepWin hasNext;末步入账确认只带 roundId balance settled: true,客户端靠 settled 区分「末屏盘面」和「入账确认」,不靠有没有 grid。禁止带未揭示步、禁止带本局总赢分 |
| -1002 | kick |
slot:{gameId}:user:{uid} |
大厅发。后进挤先进,随即 CF disconnect。无对应正数 |
reason(如 replaced) |
| -1101 | jackpot |
slot:{gameId} |
奖池变了。无对应正数。按 gameId 节流:每秒最多 1 条,多次变化合并成最新值;爆池立刻推一条 | amount(库存整数,当前池)。谁爆了、爆多少只在那个人的 -1011 / -1010 里 |
号段:成对用同一绝对值(1001 / 1010 / 1011)。单边推送用 -1002、-11xx。不用 -1003、-1004。新增先加行再发。
1.3.3 奖池怎么和局内统一
奖池是这一款游戏大家共用的一个数,不能塞进每人一条的 user 频道。走游戏频道 slot:{gameId},type 为 jackpot(-1101),游戏服改完库用 CF 服务端 API publish。频道名不带 jackpot:同一条频道以后还装大奖播报、公告,靠 code 区分。
- 节流:每款每秒最多推 1 条,期间多次变化只推最新值。1000 人在线每秒几十把 spin,不节流就是每秒几万条推送。爆池不等节流,立刻推。
- 游戏频道由 connect proxy 服务端订阅;断开自然退。不另打 HTTP 拉奖池。
- 频道不开 history。后进的人靠连上必发的 1001 → -1001 里的
jackpot拿到当前池,之后靠 -1101 更新。这样 user 频道和游戏频道可以同一个 namespace,不用为 history 拆两个。 - 这把是否爆奖、爆了多少,只在中奖者的盘面(-1011 或 -1010)里。-1101 只广播改完之后的池子余额。
- 没有单独的奖池服务、没有轮询。客户端不能往游戏频道发消息。
1.4 玩家指令:大厅 HTTP 与局内频道
玩家只连 CF,不打大厅 HTTP。点旋转和 ack 都在 user 频道 publish,由 publish proxy 转到游戏服。奖池在游戏频道收 -1101。连上、重连、等不到回包都发 1001。玩家和游戏服之间没有 HTTP。
| 谁 | 接口 | 用途 |
|---|---|---|
| 商家 → 大厅 | POST /launch | 取地址。带 merchant_uid、gameId、req_id(可带昵称头像等)。大厅 UPSERT player、签 JWT,返回带 token 的 gameUrl。不动钱 |
| Centrifugo → 大厅 | connect proxy | 验 JWT;credited 挤号放行;cashing_out 拒 retry;pending 补问商家;cashed_out / failed / 无 → 调商家全额扣款、加游戏分、插 session → 放行。响应下发 channels(user + 游戏频道)服务端订阅 |
| Centrifugo → 游戏服 | publish proxy | user 频道上的 1011 / 1010 打到任意一台游戏服。只放行正数;负数拒 |
| 大厅 → Centrifugo | presence HTTP API | 定时任务每秒一轮,每款一次。库里 credited 且已连过、presence 里没有 → 记 missing_since;连续 3 秒 → 下分。在了就清 |
| 游戏服 → Centrifugo | 服务端 API publish | -1011 / -1010 到 user;-1101 到游戏频道(节流) |
| 大厅 → Centrifugo | 服务端 API publish + disconnect | 挤号:-1002 到 user,再断旧连接 |
| 玩家 → user 频道 | publish 1001 | 回 -1001:必带当前游戏余额、当前奖池、注额档。有未完局则再带当前已揭示小局。连上必发一次 |
玩家和大厅之间没有 HTTP,玩家只连 CF。大厅 → 商家:扣款(全额)/ 加款。游戏服不调商家。大厅 ↔ 游戏服之间也没有任何接口:两边只共享 MySQL / Redis,靠 session 状态和 wallet 行锁协作。
| 路径 | 做什么 |
|---|---|
| 客户端 → user 频道 | publish 1001(空);publish 1011(reqId + 下注);publish 1010(roundId + step) |
| Centrifugo → 游戏服 | publish proxy 把 1001 / 1011 / 1010 打到任意一台。请求里带 CF 认证的 uid 和频道 |
| 游戏服 → user 频道 | API publish -1001 回 recover(余额 + 当前步);-1011 回 spin(盘面或拒局);-1010 回 ack |
| 游戏服 → 游戏频道 | API publish -1101。每款每秒最多 1 条;爆池立刻 |
| Centrifugo 内置 | ping / pong |
整串在处理 1011 时抽完落库,与扣注同一事务。任何给玩家的包都不得带未揭示步或本局总赢分。
1.4.1 游戏服无状态:publish proxy(已定)
游戏服不连 Centrifugo、不订任何频道。玩家在 user 频道 publish 1011 / 1010,Centrifugo 用 publish proxy 把这条 HTTP 打到任意一台游戏服;游戏服处理完,用 Centrifugo 服务端 API 把 -1011 / -1010 publish 回同一条 user 频道。
- publish proxy 请求里带 CF 已认证的
user(uid)和channel。游戏服只信这两个,不信 payload 里写的 uid。频道里的 gameId、uid 和 JWT 对不上就拒。 - 游戏服收到任何一条都先读这人这款最新 session:不是 credited → 忽略(正在下分 / 已下分,钱包已关)。1001:读余额、奖池、进行中大局 → API publish -1001。1011:先按 uid + gameId + reqId 查大局表 → 进行中就重推、已转完回
settled;没有就扣注、抽奖、写表、改奖池(一个事务)→ API publish -1011。奖池有变 → 节流 publish -1101。1010:只读表 → 入账 → API publish -1010。 - proxy 放行后原 1011 / 1010 会广播回频道,客户端丢掉自己的回声。负数 publish 一律拒。
- 同一 uid 串行:游戏服按 uid + gameId 拿 Redis 锁,只用来把同一玩家的 1001 / 1011 / 1010 排队;钱的互斥不靠它,靠 wallet 那一行的行锁(
SELECT ... FOR UPDATE/ 原子UPDATE balance = balance ± ?)+ round_step.paid_at 幂等。大厅下分不拿这把 Redis 锁,也能和 1010 互斥。 - 游戏服挂一台无感:CF 打到下一台。不需要分配、通知订阅、退订、接管。
| 做法 | 结论 |
|---|---|
| CF publish proxy → 任意游戏服 → API publish 回频道 | 采用。游戏服无状态。多一跳机房内 HTTP,毫秒级 |
| 游戏服连 CF 订每个玩家的 user 频道 | 不用。要分配玩家到某台、上分通知订、下分通知退订、保证只有一台、挂了要接管。整块订阅管理都省掉 |
| 玩家直打游戏服 HTTP | 没有。recover 也走 1001,游戏服不对玩家开端口 |
- 点旋转:publish 1011 → 成功回 -1011 step=0,失败回 -1011 拒局。池子变了节流推 -1101。
- 播完:publish 1010 → 游戏服从大局表取,再 publish -1010。
- 客户端不能发负数。客户端丢掉自己 1011/1010 的回声。
1.5 一次旋转(含连消)
一次 1011 开的是一条大局(一个 round_id)。有连消时,大局里面按步拆成多条小局;没有连消时,大局里只有一条小局(step=0)。
| 是什么 | 客户端怎么看到 | |
|---|---|---|
| 大局 | 点一次旋转。整串连消已经抽完,总奖额 = 各小局 stepWin 之和 |
看不见整串、看不见总奖额、看不见用的哪张表 |
| 小局 | 连消的一步:step=0 停轮,step=1 第一消,以此类推 |
step=0 是 -1011;之后每步是 -1010。播完发 1010,这一小局的 stepWin 入账;有下一步再收一条 -1010 |
- 客户端在 user 频道发 1011,带
req_id+ 下注额。转轮可以先空转。 - 先看 session 是不是 credited,不是就忽略。再查大局表(uid + gameId + reqId)。有这行且进行中:不扣注、不重抽,取出当前已揭示步推回去(step=0 用 -1011,之后用 -1010)。有这行但已转完:回 -1011
settled带余额,客户端据此换新 reqId。网络卡住又用同一 reqId 发 1011,走这条。 - 有未转完的局但 reqId 不同、余额不足、维护:publish -1011(拒局),不扣注。冻着的局必须先播完。
- 既无同 reqId 的行、也无进行中的局,才扣游戏余额,按第 3 章看水位选表、抽 1 条,整条大局写入大局表(含全部小局),奖池注资 / 爆池扣减同一事务。publish -1011
step=0(带扣后余额)。不带整串、不带本局总赢分。 - 客户端收到 step=0 再停轮。播完发 1010 ack(
round_id+step=0)。游戏服只从大局表取。没这行、或 step 小于当前revealedStep:忽略,不加分。只有对得上当前步才入账一次。 - 有下一步(这条盘面
hasNext=true):仍从大局表取下一小局,publish 下一条 -1010。这是下一屏。没有下一步(hasNext=false):入账后 publish 一条 -1010 带余额和settled: true,大局 status 改转完,不删。前端只看hasNext,末步这条不当新盘面播。不要把整把totalWin一次性加进余额。ack 不得现场再抽、再算。
不要把盘面塞进 1011。每步 1010 之后:hasNext=true 就等下一屏 -1010 再播;hasNext=false 就等入账确认的 -1010(带余额、不播),然后才能再 1011。
发了 1011 却迟迟没有 -1011:用同一个 reqId 再发 1011(先查大局表,进行中就取出,已转完回 settled),或发 1001(-1001 必带余额)。禁止换新号。-1001 说没有未完局、或收到 settled,才能用新 reqId。1010 之后等不到 -1010:可把同一条 1010 再发(旧步会忽略,末步 ack 后局已转完也会被忽略);这时发 1001,-1001 round: null + 余额就是入账确认。
1.5.0 中了免费旋转
还是同一个大局。1011 抽的时候,主转的连消、触发的免费旋转、免费旋转里的连消、再触发追加的免费旋转,全部抽完,按发生顺序编成 step 写进小局表。玩家还是一步一个 1010 往下要,服务端还是只从表取。不另开局、不记「剩余免费次数」、不在免费旋转里再收 1011。
| step | phase | 是什么 | 客户端 |
|---|---|---|---|
| 0 | 主转 | 停轮,3 个 scatter | 播停轮,弹「获得 10 次免费」,发 1010 |
| 1 | 连消 | 主转的一次连消 | 自动播消除补落,播完自动发 1010,不等玩家 |
| 2 | 免费 1/10 | 第一次免费旋转停轮 | 等玩家按键(或自动),播停轮,发 1010 |
| 3 | 免费连消 | 免费旋转里的连消 | 自动播消除,自动发 1010 |
| 4 | 免费 2/10 | … | |
| … | 中途再触发:free_total 从 10 变 13,后面多 3 步 | 弹「追加 3 次」 | |
| 末步 | 免费 13/13 | hasNext=false | 播完发 1010,收带余额的 -1010,弹免费旋转总结算 |
- 谁按键谁自动,客户端按
phase定:连消(2 / 4)收到就自动播、播完自动 1010;免费旋转(3)等玩家按旋转(开了自动模式则自动)。服务端不区分,一律等 1010 再给下一行。 - 免费旋转每步
stepWin照样按 1010 逐步入账,不等免费全部转完再一次给。断线回来 recover 到第几次就从第几次继续。 round.bet只记主转那一注;免费步不扣注。round.total_win含全部免费赢分。水位在开局记一次。- 再触发有上限(数学组定,如总数不超过 100),抽的时候到顶就停,表里 step 数有界。
- 免费旋转用哪张表:和主转同一张(同 table_id),不因为进了免费就换档。条带文件里可以给免费旋转单独一组
freeReels,但仍属于这张表、算进这张表的 RTP。 - 超时结算、下分:和连消一样。未 ack 的免费步按表逐步派彩进 wallet 再下分,不重抽。
为什么不另开局:另开局要记「这人还有 7 次免费没转」,断线、超时、下分、换设备再进每一处都要处理这个状态,而且免费局 bet=0 会让注单和 RTP 报表多一类特殊行。放进同一大局,这些全都不存在。代价是一把的步数变多、-1011 之后玩家要按十几次,但这正是免费旋转本来的玩法。
1.5.1 为什么局内都走频道
局内已经要求 CF 连着,盘面也必须走 CF。spin 再打一条 HTTP 和 ack 两套路径,没有额外好处。扣钱成败用 -1011 回,靠 req_id 幂等,丢了靠 recover。
ack 闸门仍要:整串已经抽完落在大局表里。1010 只能从表取,不能一次把后面几消都给前端,也不能现场再算。
| 播完的是 | 客户端干什么 | 怎么拿到下一块 |
|---|---|---|
中间步(hasNext=true) |
发 1010 | 再收一条 -1010,这是下一屏,继续播 |
末步(hasNext=false) |
发 1010 | 这一小局入账后收一条带余额的 -1010,不当新盘面播。然后才能再发 1011 |
前端不要自己猜还有没有连消,只看当前盘面的 hasNext:true 再等下一屏,false 这一屏就是最后一屏。未结束前不能再发 1011。
1.5.2 重复 1010 与 1001 recover(已定)
1010 入账只发生一次。-1001 每次都带回当前游戏余额。
| 1010 的 step | 做什么 |
|---|---|
| session 非 credited,或没有进行中的大局 | 忽略。不加分、不报错当新局。末步 ack 的 -1010 丢了 → 客户端发 1001,用 -1001 的余额当确认 |
小于当前 revealedStep(旧 ack 重发) |
忽略,不加第二遍分。可以把当前步再推一次 |
等于当前 revealedStep |
这一小局 stepWin 入账一次,再推下一步或末步余额 |
大于当前 revealedStep(还没揭示到) |
忽略。不加分、不跳步 |
| 1001 时 | -1001 返回 | 客户端接着怎么做 |
|---|---|---|
| 大局表有未完局 | 当前余额 + 当前已揭示步(step=0 同 -1011,之后同 -1010)+ 原 reqId | 继续播、用原 roundId ack。不要换 reqId 再 1011 |
| 没有进行中的大局 | 当前余额,没有盘面 | 等 -1011 却丢了:可用原 reqId 再发 1011(进行中取出;已转完回 settled)。已经 ack 完、hasNext 已是 false:换新 reqId 再 spin |
1001 不抽奖、不改大局表、不改水位。只读。每次连上 CF 后固定先发一条,频道不开 history。同一 uid 串行,1001 和在途的 1010 排队,-1001 回的一定是排到它时的状态。
1.6 硬约定
- 上分:商家
/launch只取地址不动钱;玩家连上 CF 时大厅在 connect proxy 里调商家按余额全部扣走、加游戏分,才放行连接。第一次和再进同一条。下分认presence 连续 3 秒没有这个 uid。大厅不连 CF。游戏服不报断开。 - wallet 只有五种改法:大厅上分加、游戏服 1011 扣注、游戏服 1010 / 超时任务派彩、大厅下分扣光、后台运营科目。谁改都是同一行行锁 + 同事务一行 wallet_log;大厅和游戏服之间没有接口。游戏服处理任何一条消息先校 session 是 credited。ack 只能从大局表取,不现场再算。
- 局内指令和盘面走
slot:{gameId}:user:{uid};奖池走slot:{gameId}。同一套信封。游戏服无状态,任意一台处理;同一 uid 按锁串行。 - 改钱都要能对账:上分/下分 HTTP 带
req_id;spin 1011 带req_id;每一局有round_id,开奖大局写入大局表,按小局下发。扣注、流水、写表同一事务;水位在事务外记,不进事务。 - 1011、1010、-1011、-1010 都不得带尚未揭示的步,也不得带本局总赢分。
- 客户端不本地加减余额。扣注成败看 -1011;盘面看 -1011 / -1010(recover 保底);每一小局入账看这次 1010 之后的 -1010 余额。下分不推消息,人已断。
- 心跳用 Centrifugo 自带,游戏层不再做 ping。
- 踢人:大厅先 API publish -1002,再调 Centrifugo
disconnect。 - 空闲游戏分:presence 连续 3 秒不在才下。拿了
gameUrl不连的钱没动过,不用退。下分固定顺序:条件更新 credited → cashing_out,CF disconnect,大厅行锁内扣空闲分(与 1010 同一行锁),商家加款,cashed_out。先关门再搬钱。未完连消不断开结算。
1.7 待后续完善
- HTTP 路径、鉴权头、错误码(可重试 / 不可重试)。
- Centrifugo 配置:namespace
slot(history 关、presence 开)、token TTL 24h、connect proxy(返回 channels,proxy_connect_timeout5 秒)、publish proxy 指到游戏服、presence 开启、ping 间隔、服务端 API key。 -1011 / -1010 data盘面字段表(两套盘面字段相同)(符号矩阵怎么编码)。- 商家扣款/加款 API 字段、签名、幂等。
- 大厅定时任务的锁(Redis)、轮询周期(默认 1 秒);单子表
connected_at/missing_since两列;status 流转 pending → credited → cashing_out → cashed_out 全部用条件更新。 - JWT 24 小时内可重复连:是否给商家一个「作废 token」接口(玩家在商家侧退出登录时调)。
- 压测:CF 连接数、1011/-1011 延迟、1001/-1001 延迟。
2. 上下分
上分只有一条:商家调 /launch 取地址(不动钱)→ 玩家连 CF → connect proxy 里大厅调商家按该用户余额全部扣走、加游戏分、插 session → 放行。第一次和下分后再进完全一样。下分:大厅定时任务轮询 presence,连续 3 秒不在才下空闲分。大厅无状态,状态全在单子表。游戏服不报断开、不调商家。
2.0 流程图
左半是上分(全部在 connect proxy 里完成,四路分支对应 2.1.1),右半是下分(每秒轮询任务,对应 2.4)。黄菱形是判断,红框是拒绝 / 终止,绿框是正常终点。
| 怎么知道 | 大厅做什么 |
|---|---|
商家 POST /launch |
UPSERT player,签 JWT(24h),返回 gameUrl。不插 session、不动 wallet、不调商家 |
| connect proxy | 验 JWT → 取锁 → 按 session 分四路:credited 挤号放行 / cashing_out 拒 retry / pending 用原 transfer.id 补问商家 / cashed_out、failed 或无 → 插 pending → 调商家全额扣 → wallet+ → credited → 放行。放行响应下发 user + 游戏频道服务端订阅,写 connected_at、jti |
| 定时任务轮询 presence(每秒) | credited 且连过、presence 里没有 → 写 missing_since;在 → 清空;missing_since ≥ 3 秒 → 关门(cashing_out)→ disconnect → 大厅行锁内扣空闲分 → 商家加款 → cashed_out。不调游戏服 |
2.1 取地址:POST /launch(商家调)
- 商家带
merchant_uid、gameId、req_id,可带 nickname / avatar / vip_level / lang(带了就覆盖 player)。签名用商家密钥。不带金额。 - 大厅校商家签名、game 上架、玩家未冻结;UPSERT player(ns + merchant_uid → uid)。
- 签 JWT:
sub=uid、gameId、jti(随机)、exp24 小时。拼进gameUrl返回,同时回orientation等展示信息。 - 失败直接回错,无地址。这一步没有任何钱的动作,重复调多少次都无副作用。
同一个人多次取地址:每次新 jti,旧 token 到期前也能连。谁先连 connect proxy 谁把 jti 写进 session,后来的挤先来的。
2.1.1 连上即上分:connect proxy(POST /internal/cf/connect)
钱只在这里动。Centrifugo 把 JWT claims 和这条连接的 client id 打过来,大厅在超时(5 秒)内完成下面的事,返回放行 + channels,或拒绝 + reason。
- 验签、验 exp;拿 uid / gameId;读 player.status,冻结 → 拒
denied。 - 取 Redis 锁
lock:connect:{ns}:{uid}:{gameId}(TTL 6 秒)。拿不到 → 拒retry。同一个人两条连接同时进来,只有一条会去调商家。 - 查这人这款最新一行 session:
session 做什么 动钱 credited 挤号:API publish -1002 到 user 频道;CF disconnect(user=uid,whitelist=这条新 client);UPDATE session SET jti=新, missing_since=NULL, connected_at=now。放行否 cashing_out 拒 retry。下分几百毫秒就完,客户端 1 秒后重连落到下一行否 pending 上次调商家没拿到结果。用这行的 transfer.id 再调商家扣款(商家幂等)。拿到金额 → 走第 5 步补 credited → 放行;又超时 → 拒 retry;商家明确失败 → session failed、transfer 失败 → 拒denied补问 cashed_out / failed / 没有 新上分,第 4、5 步 是 - 事务一:
INSERT session(status=pending, jti, client)+INSERT transfer(direction=1, amount=NULL, status=0, session_id)。提交后调商家扣款{merchant_uid, gameId, req_id=transfer.id},超时 3 秒。商家按该用户余额全部扣走,返回amount(0 也是成功)和merchant_tx_id。 - 事务二:
UPDATE transfer SET status=1, amount, merchant_tx_id·UPDATE wallet SET balance += amount·INSERT wallet_log(101, +amount)·UPDATE session SET status=credited, amount_in=amount, connected_at=now·player_game.sessions += 1。amount=0 时先查 round(active=1):没有 → session failed、拒no_balance(transfer 仍记 status=1 amount=0);有 → 照常 credited。 - 放行:响应
channels: [slot:{gameId}:user:{uid}, slot:{gameId}]。释放锁。
拒绝的 reason 只有三个:retry(客户端 1 秒后自动重连,最多 5 次;5 次仍被拒就按 denied 处理,提示「网络异常」回商家重新取地址)、no_balance(提示回商家充值)、denied(回商家重新取地址)。
2.1.2 失败怎么收(已定)
| 情况 | 处理 |
|---|---|
| 取地址失败 | 回错,无地址。没有钱的动作 |
| 拿了地址不连 / JWT 过期 | 什么都没发生,不用退。过期后连 → denied,回商家再取 |
| 商家扣款明确失败 | session failed、transfer status=2。拒 denied。没有游戏分,没有会话 |
| 商家扣款超时 / 未知 | session 留 pending、transfer status=3。拒 retry。下次连上用同一 transfer.id 再调,商家幂等只扣一次。不冲正 |
| 商家已扣成功、我们事务二失败 | 同上:session 仍 pending,下次连上再调商家,商家返回已扣的同一结果,我们补做事务二。钱不丢 |
| 商家返回 0、无未完局 | session failed,拒 no_balance |
| 商家返回 0、有未完局 | credited、加 0 分、放行。只能 ack 播完,1011 会被余额不足拒局 |
| 已 credited 再连(换设备 / 3 秒内重连) | 不调商家、不叠加游戏分。挤旧连接 |
| cashing_out 时连 | 拒 retry,等下分完再连就是新上分 |
| pending 超过 10 分钟 | 对账任务用 transfer.id 问商家一次:扣了就补事务二把钱进 wallet,然后条件更新 pending → cashing_out,走 2.4 的下分把钱退回商家,最后 cashed_out(人不在线,不需要 disconnect);没扣就 failed。人再连时若还是 pending 也走 2.1.1 第 3 步;对账任务和 connect proxy 靠同一把 lock:connect 互斥 |
2.2 为什么在 connect proxy 里扣
- 钱和连接同生:钱到游戏里的那一刻人一定在线,不存在「加了分没人玩」。发地址 30 秒不连要退回的任务整个删掉。
- 商家只对接三件事:取地址、被扣款、被加款。方向固定:钱永远是大厅去拉、去推,商家不主动推。
- 第一次和再进一条流程,代码和状态机只有一套。
- 代价:connect proxy 里多一次商家调用,Centrifugo
proxy_connect_timeout从默认 1 秒调到 5 秒,商家调用 3 秒超时。超时就拒 retry,不会把连接和扣款拆成两半——扣款结果落在 pending/transfer 上,下次连上接着问。
2.3 接口
| 谁 | 接口 | 要点 |
|---|---|---|
| 商家 → 大厅 | POST /launch | 取地址。merchant_uid、gameId、req_id、可选展示字段。返回 gameUrl(JWT 24h)、orientation。不动钱 |
| Centrifugo → 大厅 | connect proxy | 验 JWT;按 session 分路;新上分时调商家扣款、加游戏分、插 session;放行下发服务端订阅;拒绝带 reason |
| Centrifugo → 游戏服 | publish proxy | 1001 / 1011 / 1010 打到任意一台游戏服 |
| 大厅 → Centrifugo | presence | 定时任务每秒每款一次,拿在线 uid 集合 |
| 大厅 → Centrifugo | publish / disconnect | 挤号 -1002 + 断旧连接(whitelist 新 client);下分关门后 disconnect |
| 大厅 → 商家 | 扣款(上分) | 请求 {merchant_uid, game_id, req_id=transfer.id, ts, sign},不传金额。商家把该用户余额全部扣走,返回 {amount(库存整数,0 也成功), merchant_tx_id, balance_after}。按 req_id 幂等:同一 req_id 重复调返回第一次的 amount 和 merchant_tx_id,不再扣。大厅只校 amount 非负整数、不超单次上限,不核对商家余额 |
| 大厅 → 商家 | 加款(下分) | 请求 {merchant_uid, game_id, req_id=transfer.id, amount, ts, sign},返回 {merchant_tx_id, balance_after}。金额 = 空闲游戏分(含已 ack 的小局赢分),不含未 ack 的连消赢分。按 req_id 幂等 |
| 大厅 → 商家 | 查询(对账) | 按 req_id 查一笔扣款 / 加款的结果。pending 超 10 分钟的对账任务用 |
商家要实现的只有三个回调:扣款、加款、查询,都按 req_id 幂等。我们对商家开放的只有 /launch。表里没有「大厅 → 游戏服」「游戏服 → 大厅」这两行:两者之间没有接口,只共享 DB / Redis。
2.4 下分(已定)
OSS 没有 disconnect HTTP 回调,也不靠 join/leave。大厅不连 CF,用一个带 Redis 锁的定时任务每秒轮询 Centrifugo presence API。「掉线」的定义:库里说他该在,presence 连续 3 秒说他不在。
对比的两边:expected = 单子表里 status=credited 且 connected_at 非空的 uid(connect proxy 放行时写);online = presence slot:{gameId} 返回的所有 user(JWT sub 填 uid 才对得上)。一款一次调用拿齐全部在线人。
- 每轮对每款游戏:
uid ∈ expected且∉ online→missing_since为空就写 now,不为空不动。uid ∈ online→missing_since清空。 missing_since距今 ≥ 3 秒且本轮仍不在,按固定顺序下分:UPDATE 单子 SET status='cashing_out' WHERE id=? AND status='credited'。0 行 → 退出。此后 connect proxy 拒绝这单。- CF
disconnect该 uid。已连着的踢掉。 - 大厅自己一个事务:
SELECT wallet FOR UPDATE→ 取 balance 为 amt →UPDATE wallet SET balance=0→INSERT wallet_log(−101, −amt)→INSERT transfer(direction=2, −amt, status=0)→session.cashout_amount=amt。与游戏服 1010 靠同一行行锁互斥:在途 1010 要么先入账再一起扣走,要么排后面读到 session 已是 cashing_out 而忽略。不调游戏服。 - 商家加款(幂等,带 req_id)。
- 单子 status='cashed_out'。
- 同一轮顺手处理:未完局超时(4.3)、pending 超 10 分钟对账(2.1.2)。这两条最后都要「再下一次分」,走的就是上面第 3~5 步,只是 session 由任务先条件更新到 cashing_out(从 cashed_out 或 pending),transfer 挂原 session_id。没有「发地址 30 秒不连」这条:连上之前钱没动。
- presence 调不通(CF 挂、超时):本轮整款跳过,不写 missing、不下分。不能把「问不到」当「不在」,否则全员下分。
- 量级:
presence返回该频道每个 client 的完整信息,一款几千人在线就是每秒几 MB JSON,是对 CF 的重操作。上线前按峰值在线数压一次;超过阈值(如 2000 人 / 款)把周期放到 2 秒,或先调presence_stats看人数没变就跳过本轮。3 秒判定不变。 - 任务用 Redis 锁保证同一时刻只有一台在跑;每台大厅都起这个任务,谁抢到谁跑。哪台挂了,下一秒别的接上。
- 下分之后再进:客户端拿原 JWT 再连(没过期),或回商家重新
/launch取地址再连。connect proxy 见 session 已 cashed_out → 新上分:调商家全额扣、加游戏分、插新 session(credited) → 放行(2.1.1)。余额为 0:有未完连消则放行加 0 分(只能 ack 播完),没有未完局拒 no_balance。 - 连上 CF 先发 1001:-1001 从大局表取出当前
revealedStep(step=0 同 -1011,之后同 -1010)。继续播、发 1010,每步只加该小局 stepWin。全部 ack 完大局改转完。未结束前不能再 spin。
3 秒内重连:用原 JWT 再连,session 还是 credited,不调商家。满 3 秒后再连:session 已 cashed_out,同一个 JWT 再连就是一次新上分。空闲分为 0(钱都在未完连消里)时,满 3 秒也要记「会话断了」,但不调商家加 0。上分金额永远是商家返回的数,客户端不填。
长期不回来的未完局要有超时:按大局表已有小局把剩余步结算完,赢分下分给商家,大局改「超时结算」。不重抽。时限待补。由同一个定时任务跑。
超时只能打「已经下过分、并且此刻没有进行中会话」的行。人已经重连回来(又 credited、正在 ack)的,这条任务不准动:不结算剩余步、不把分打给商家、不改状态。3 秒窗口内还没下分的,也不打。
2.5 断开谁能收到(已定)
下分只有大厅这一条链:轮询 presence → cashing_out → disconnect → 扣空闲分 → 商家加款 → cashed_out。游戏服不报断开、不调商家。幂等靠第一步条件更新:同一单只有一次能从 credited 改成 cashing_out。
| 情况 | 大厅 | 游戏服 |
|---|---|---|
| 玩家这条游戏 CF 连接断了(杀进程、断网、被 kick) | presence 里没他 → 记 missing_since;连续 3 秒 → 关门、disconnect、下分。挤号时新连接已在,不受旧连接影响 |
无事。无状态,不订频道 |
| 人还在大厅页,只是退出老虎机页,但这条游戏 CF 还连着 | 不下分。presence 里还有他 | 无事 |
| Centrifugo 挂了 / presence 调不通 | 本轮跳过,不写 missing。不当全员下线 | 无事 |
kick:大厅先 API publish -1002,再 CF disconnect。新连接已在 presence 里,轮询看到他在,不下分。
3. RTP:水位选表与返水
不在运行时改权重、不筛结果。数学组给一组完整的赔付表:按「风格 × RTP 档」排成表格,风格(温和 / 刺激)运营在后台选,RTP 档(低 / 中 / 高)每把 1011 看水位自动切。选定一张,抽 1 条就是结果。水位是负反馈信号:吐多了切低表、吐少了切高表,长期 RTP 精确收敛到运营填的目标。返水仍在转轮外补差额。
3.0 流程图
左列是每把 1011 的路径(幂等 → 拒局 → 读水位 → 选表 → 抽 → 事务 → 事务外记水位,对应 3.3 / 3.4);右上是运营后台改目标 / 风格怎么生效(3.8);右下是返水在转轮外怎么算、怎么发(3.6)。
3.1 三层各管什么
| 赔付表 | 水位 | 返水 | |
|---|---|---|---|
| 问的是 | 这把从哪张表抽(每张表 RTP 固定、完整合法) | 到现在吐多了还是吐少了,选哪张表 | 玩家有效回报还差那一截 |
| 管哪一步 | 1011 抽 1 条完整大局 | 1011 抽之前读一下、抽之后记一下 | 下分或商户日结,按流水给 |
| 不干什么 | 不按人改权重;不在运行时改任何一张表 | 不否决结果、不筛、不重抽;不按人建库存;不进事务 | 不塞进盘面、不进 -1011/-1010 赢分 |
对外可说的总回报 ≈ 游戏内 RTP + 返水率(返水按流水时)。两层数字要一次配齐,不要游戏里已经留足 hold、厅里再高额返,叠成超发;也不要两边都克,玩家实拿对不上宣传。
3.2 已定口径
- 一组静态赔付表,两个维度:风格(温和 / 刺激,运营选)× RTP 档(低 / 中 / 高,水位切)。每款最少 2 × 3 = 6 张。同一风格内三张表符号、玩法、连消规则、命中率、特色玩法频率一致,只有大奖权重不同,RTP 落在例 94 / 96 / 98。每张表单独合法、单独可审计。运行时不改任何一张。
- 风格是可玩性维度,不是 RTP 维度:温和版命中率高、连消短、顶奖低、本金撑得久;刺激版命中率低、连消长、顶奖高、大起大落。两种风格在同一 RTP 档下长期吐回一样多,只是吐法不同。指标见 3.10。
- 水位按
gameId一份共享(Redis keywater:0:{gameId},ns=0 表示全商户共用;商户单独配了 game_config 才有自己的water:{ns}:{gameId}),跨天累计,不按自然日清零。定义:水位 = Σ 下注 × 目标RTP − Σ 实际派彩。正数欠玩家,负数玩家赢多。 - 每次 1011:读水位 → 按阈值选表 → 从那张表抽 1 条完整大局(含全部小局)→ 落库。不筛、不重抽、不看总奖额是否「发得出去」。抽到什么派什么。
- 选表规则:先按后台当前风格取那一组;组内
水位 > +阈值用高表;水位 < −阈值用低表;其间用中表。阈值按「多少倍平均注」配(例 ±500 注)。这是负反馈:长期 RTP 被拉到目标值,风格切换不影响收敛。 - 不关台、不拒注、没有「水位不够只出小奖」。水位再负也只是切到低表,低表照样能出大奖,只是概率低。
- 落库只留开奖那条大局,写入大局表(大局一行 + 小局每步一行,永久),记
tableId和开局时水位。1010 只能从这张表取。断线回来 / recover 也是。不重抽、不现场再算。 - 返水不算进转轮。游戏服只记流水;钱由大厅/商家发到用户真钱账户。
3.3 水位怎么算、怎么选表
每把结束记一次:水位 += 下注 × 目标RTP − 该局总奖额(目标 97 就是 ×0.97)。下 100 没中就 +97;下 100 中 500 就 −403。
水位不进事务、不加锁、不 CAS。一条 Redis INCRBY(库存整数)或库里 autocommit 的 UPDATE water SET v = v + ?,写完就放。它是选表信号,不是钱:晚一秒、差几块,最多让下一把多用一次某张表,不影响任何一局的正确性。定时把 Redis 值落库做审计。
选表读的是快照,不锁。同一毫秒两把都读到同一个水位、都选高表,完全没问题。
为什么长期精确:吐多了 → 水位负 → 低表 → 吐得少 → 水位回升;吐少了反过来。水位始终被往 0 拉,而水位 = 0 就是实际 RTP = 目标。三张表没有一张是 97,轮着用就是 97,运营填 96.5 就收敛到 96.5。
3.4 一次 1011 里怎么走
- 先校 session 是 credited,不是就忽略。再按 uid + gameId + reqId 查大局表。有这行:不扣注、不抽、不动水位;进行中就取出当前已揭示步 publish(step=0 用 -1011,之后用 -1010),已转完就回 -1011
settled带余额。 - 没有这行。余额不足、已有进行中局(reqId 不同)、维护:publish -1011(拒局),不抽、不动水位。
- 读后台当前风格和目标 RTP、读水位(快照,不锁)。风格定组、水位定档,得到 tableId。锁死本局目标 RTP 和 tableId。
- 从选中的表抽 1 条完整大局:主转、连消、触发的免费旋转及其连消、再触发,每个小局都算完,总奖额 = 各小局之和(含是否爆池)。不筛。
- 开事务:扣注(wallet)、写扣注流水(wallet_log)、把开奖大局(
UNIQUE(ns, uid, gameId, reqId),含 tableId、开局水位)和全部小局写入大局表、奖池jackpot_pool.amount += bet × contribution − jackpot_win。提交。失败整体回滚,注不扣、池不动。 - 事务外:
水位 += 下注 × 目标RTP − 总奖额。然后 publish 当前小局 -1011 step=0。奖池在事务里已经改了,节流 publish -1101 带jackpot_pool.amount。
返水不在这条路径上。1010 派彩只加当前这一小局的 stepWin。
3.4.1 大局表(已定)
开奖结果进大局 + 小局两张表,永久保留。同一 uid + gameId 最多一条进行中大局。字段级定义见 第 5 章。
| 字段 | 干什么 |
|---|---|
roundId | 大局主键。客户端 1010 / recover 都带它 |
uid gameId reqId | 谁、哪款、哪次 1011。1011 先按这三键查表,有就取出,不插第二行 |
bet targetRtp tableId waterAt totalWin | 下注、开局锁住的目标 RTP、用的哪张表、开局时水位、各小局奖额之和。审计一句话说清 |
| 全部小局 | 每步盘面、stepWin、hasNext,一步一行永久存(5.2 小局表)。落库时已经齐,ack 不再算 |
revealedStep | 最后一次已经推给客户端的小局。断线回来只重推这一步,不把后面几消带出去 |
- 1010:只能从大局表取。没这行:忽略。发来的 step 小于
revealedStep:忽略,不加第二遍分。step 大于当前:忽略。只有step == revealedStep:把这一小局stepWin加一次。有下一步:把revealedStep写成下一步并 publish 下一小局。最后一步:入账后 status 改转完,不删。不要改小局内容,不要现场再算,不要把totalWin一次性加。 - 1001 recover:-1001 必带回当前游戏余额。有未完局再带当前步;没有未完局只带余额。不抽、不改表。
- 水位在开奖那把已经按净额记过。断线回来、recover、超时结算都不再动水位。
- 同一
reqId再来 1011:先查大局表,不扣第二次。进行中的行 → 取出当前步重推;已转完 / 超时结算的行也永久在,同 reqId 永远命中唯一键、永远不开新局,但不重推盘面——重推末屏会让客户端再发 1010,而那时已没有进行中的局,1010 被忽略,客户端永远等不到确认。改回 -1011reason: settled+ 当前余额,客户端据此换新reqId。
3.4.2 状态怎么变(已定)
| 时机 | 做什么 | 不做什么 |
|---|---|---|
| 最后一步 1010(转完) | 这一小局 stepWin 入账,大局 status 改转完。下一把可以 1011 | 不把整把 totalWin 再加一次。不整表 TRUNCATE |
| 断线 | 行留着 | 不删。人回来还要从表里取当前小局 |
| 未完大局超时 | 只打已下分、且此刻这人这款没有 pending / credited / cashing_out 会话的行。把剩余步按表结算完进 wallet,session 由任务 cashed_out → cashing_out 再走一次下分给商家,status 改超时结算 | 不重抽。人已经重连回来接着 ack 的,不准动这行。时长见第 4.3 节 |
大局表永久保留。「进行中」只是 status 的一个值,转完 / 超时结算都只改状态。
3.5 可见奖池和水位
- -1101 那个数是玩家看见的奖池 =
jackpot_pool.amount(5.4.3)。每把在 1011 事务里注资 / 爆池扣减。推送每款每秒最多 1 条,爆池立刻;新连上的人从 -1001 拿当前值。 - 水位是内部信号,玩家看不见。它不决定爆不爆池;爆池概率是表定的,水位只决定用哪张表。
- 谁爆了、爆多少只在中奖者盘面(-1011 或 -1010);-1101 只广播改完后的池余额。
3.6 返水(转轮外)
返水用来补「有效 RTP」,不要做成第二套抽奖。
- 计法默认按有效流水(扣注成功的下注额)。不要按单局输赢再随机加减,那等于改盘。
- 可以按 VIP 分档(不同人不同返水率)。分档只影响转轮外打款,不改变赔付表、不改变水位。
- 发放:大厅在下分给商家加款时一并结算,或商户日结。进用户真钱账户,不加进游戏余额,避免和未完连消、空闲分搅在一起。
- 游戏服给大厅的下分/流水对账里带本局(或本会话)有效流水即可,游戏服自己不调商家发返水。
3.7 怎么验
- 上线前:每张表单独跑百万把,确认各自 RTP、爆池频率、最高倍数。再按「读水位 → 选表 → 抽 1」跑百万把,看收敛到目标要多少把、水位摆动幅度、三张表各用了多少比例。
- 上线后:按
gameId看游戏内派彩/投注、水位曲线、三张表使用占比。另报「游戏内 RTP + 已发返水 / 流水」。 - 漂移超过阈值告警。禁止改单人权重、禁止运行时改表;要动就改后台目标 RTP 或阈值,game_config 记 updated_by / updated_at。
- 审计每局只需
tableId+ 开局水位 + 总奖额,对着那张表就能复核。
3.8 后台怎么设(已定)
运营两个控件,随时改、下一把生效:
- 目标 RTP:输入框,随便写,97、98、96.5 都可以。系统按这个数算水位,超出表能覆盖的范围时用返水补。
- 风格:单选,温和版 / 刺激版(数学组交几种就几个选项)。决定用哪一组表。改风格不清水位、不改 RTP。
运营不改符号、不配权重表、不直接选某一张表。RTP 档由水位自动切,运营看不到也不用管。
| 运营填的 | 系统怎么贴 |
|---|---|
| 在最低表和最高表 RTP 之间(例 94~98) | 水位选表自动收敛到目标;返水 0 |
| 高于最高表 RTP,且不超过「最高表 + 返水上限」 | 转轮按最高表跑满;差额用返水补。合计 = 运营填的数 |
| 低于最低表 RTP,或高于「最高表 + 返水上限」 | 拒绝保存,提示当前可填区间 |
- 步进 0.1 即可(97.5 可以)。不必支持 97.123456。
- 同一页给运营看:当前水位、最近三张表使用占比、实测 RTP、返水、合计。填完就能看见,避免以为改了转轮手感。
- 改目标、改风格都对下一把 1011生效。进行中的连消锁开局时的目标 RTP 和 tableId,不中途改。改任何一项都不清水位,水位继续按新目标累计。
- 默认按
gameId一份目标 RTP + 一个风格。商户要不同也可以各自配,各自一份水位,用同一批表,不能改权重。 - VIP 若再加返,后台要显示「基础目标 + VIP 加返 = 实发」,防止和填的数叠成超发。
- 每局落库当时的目标 RTP 和 tableId,实测按这两个对。
3.9 待配数字
口径已定,下面这些要产品/数学组给数再填:几种风格、每种风格各档的 RTP 与最高倍数、切表阈值(多少倍平均注)、每种风格的可玩性指标(3.10)、返水上限、未完大局超时、模拟次数与漂移阈值、1011 耗时上限。可填区间写在后台输入框旁。
3.10 可玩性指标(数学组交付清单)
RTP 只说长期吐回多少,可玩性说这钱怎么吐。同样 96%,可以是每把中一点的温和机,也可以是十把不中一把翻五倍的刺激机。这些全在表的形状里,和 RTP 独立。每张表交表时下面这些数一起交。
| 指标 | 是什么 | 温和版 | 刺激版 |
|---|---|---|---|
| 命中率 | 多少把有赢(含赢小于注) | 高(30%+) | 低(20%~25%) |
| 波动率 | 赢的大小分布多散 | 低~中 | 高 |
| 连消触发率 / 平均长度 | 多少把进连消、平均消几步 | 触发多、步数短 | 触发少、步数长 |
| 特色玩法频率 | 免费旋转等多少把进一次 | 密 | 稀 |
| 大奖频率 | 多少把出一次 ≥50 倍、≥200 倍 | 稀 | 密(靠条带上大奖符号多,不靠改倍数) |
| 小赢占比 | 赢小于注的比例 | 高 | 低 |
| 本金曲线 | 100 注平均撑多少把 | 长 | 短 |
具体数字由数学组和产品定,表里只是方向。
- 表 = 条带。赔付倍数和连消倍率全款只有一份,写在游戏说明里玩家看得见,任何风格、任何档都不改。6 张表只是 6 份转轮条带(每个符号占几格、怎么排),条带定概率,玩家看不见。
- 同一风格内三张 RTP 档必须一致:命中率、连消触发率、特色玩法频率、小赢占比只允许极小误差,只有条带上大奖符号的格数不同。差两三个点退回去。玩家在档之间切换感觉不出来。
- 风格之间可以差很多:低价符号、空格的格数和排布整体不同,命中率、连消长短、大奖频率全变。这正是运营要选的东西。
- 「差点中」只在表现层:结果已经是不中,客户端把最后一轮停慢一点、停在差一格。禁止为了做差点中而改结果。
- 转轮时长、连消步间隔、大奖前停顿、音效是客户端节奏,和表无关,另立客户端规范。
- 新手期默认不做。若要做(前 N 把用高命中率表),是按人选表,需要产品和法务拍板,且要确认目标市场允许;做法只是多一组表 + 选表规则多一个「uid 累计把数 < N」条件,其余不变。
4. 断线
断开超过 3 秒才下分空闲分;3 秒内重连当没断过。下分之后再进:再连一次,connect proxy 里大厅调商家全额上分(余额 0 且有未完局也放行);连消播到一半必须接着播。连上先 recover,从大局表读,不重抽。
4.0 流程图
主线从「连接断了」开始:3 秒内重连不动钱;≥ 3 秒下分;人回来再连就是新上分,回不来走超时任务;两条路都汇到 1001 → 从 revealed_step 接着播。右上是「连着但等不到回包」的处理。
4.1 两种「断」
| 情况 | 做什么 |
|---|---|
| 游戏 CF 连接断开,3 秒内又连上 | 不下分。原会话继续,用原 JWT 再连,connect proxy 重新订好,先 recover。大局表照旧。 |
| 游戏 CF 连接断开超过 3 秒 | 关门(cashing_out)→ disconnect → 大厅行锁内扣光 wallet → 商家加款 → cashed_out。游戏服不参与。大局表这一行不动。再进:原 JWT 再连(或回商家再取地址)→ connect proxy 见 cashed_out → 大厅调商家全额扣(0 也行)→ 加游戏分、新 session → 放行 → 1001 取出当前小局继续。 |
| Centrifugo 仍连着,发了 1011/1010 却等不到对应的 -1011/-1010 | 不下分。发 1001:-1001 必带当前余额;有未完局则带当前 revealedStep。等 1011 回包时用原 reqId 再发(进行中重推、已转完回 settled)或发 1001,不要换新号。 |
4.2 断线回来从大局表取
- 1011 当时已经把整条大局(含未揭示小局)写进大局表。断线不改状态、不改小局、不重抽。
- 断开时不下发后面几消的赢分,也不把
totalWin算进本次下分。 - 回来只有一个动作:再连。3 秒内 session 还是 credited,放行不动钱;满 3 秒已下分的,connect proxy 见 cashed_out 就调商家全额上分、加游戏分、插新 session 再放行。两种都是连上后先发 1001:游戏服
SELECT该 uid+gameId 进行中的那一行(有就是未转完),回 -1001。 - recover 按表上的
revealedStep返回当前步(step=0 同 -1011,之后同 -1010)。玩家继续发 1010;下一步仍从表里取,不是现场再算。 - 这人这款没有进行中大局才允许新的 1011。有未转完大局却再 spin → -1011 拒局。
- 回来继续 ack。每步 1010 把这一小局 stepWin 打进这次新上分后的游戏余额。全部小局 ack 完,大局改转完。
4.3 未完局超时(口径已定,时长待补)
超时任务和人回来撞上:以会话为准。已经重连加过分、正在播的,这行还在用,超时任务碰不得。任务和 connect proxy 抢同一把 lock:connect:{ns}:{uid}:{gameId},拿到锁再查会话再动手。
- 可以打:这人这款最新 session 是 cashed_out(已下过分),没有 pending / credited / cashing_out 的会话。按表把剩余小局逐步派彩进 wallet(wallet_log 201/202,paid_by=2),大局改超时结算;再条件更新 session
cashed_out → cashing_out,走 2.4 的下分把 wallet 退给商家(transfer 挂这张 session),最后回 cashed_out。不重抽,不绕过 wallet 直接给商家。 - 不准打:3 秒内还没下分;或已经再上分回来、正在 ack。
- 动手前再查一次会话。已经回来了就跳过,不要改状态、不要把分打给商家。
- 超时多久:产品补数。
4.4 待后续完善
- 轮询周期默认 1 秒、Centrifugo ping 间隔(决定断线多久从 presence 消失)待定。
5. 数据表设计
金额一律库存整数(最小货币单位,BIGINT),不用小数。流水类的金额带符号:正数加钱、负数减钱,玩家余额 = 流水求和,不靠方向字段翻译。RTP、返水率、倍率一律存 ×100 的整数(97.5% → 9750;sim.rtp、rebate_rate、multiplier 同精度)。所有表带 ns(商户/命名空间),查询必带。时间 UTC0。
大局表 + 小局表(永久,大局一行、每步一行,转完不删,回放用)、游戏表(固有属性 + 赔付倍数)、游戏配置表(6 张条带 + 运营改的目标 RTP、风格)、奖池表、水位快照、上下分五张(上分单 / 上下分流水 / 游戏余额 / 余额流水 / 交易科目)、用户两张(用户 / 用户游戏统计)。
5.1 大局表 round
一把一行,就是注单。每次 1011 扣注成功写一条,和小局表同一事务插入。永久保留,转完不删:转完只更新 paid_win / status / settled_at。回放、对账、RTP 报表都从这张表起步,同一大局的每一步在 5.2,round_id 关联。
| 字段 | 类型 | 说明 |
|---|---|---|
round_id | BIGINT PK | 大局号,一把一号。小局表、wallet_log、客户端 1010 / recover / replay 里的 roundId 都是它 |
ns | INT | 商户 |
uid | BIGINT | 玩家 |
game_id | VARCHAR(32) | 哪款 |
req_id | VARCHAR(64) | 客户端 1011 带的。UNIQUE(ns, uid, game_id, req_id):同 reqId 重发被挡住,不双扣;1011 先按它查,查到就重推 |
session_id | BIGINT | 这把属于哪张上分单,下分对账用 |
bet | BIGINT | 下注 |
table_id strips_version | VARCHAR(16) / INT | 用的哪张条带(如 hot.high)和当时的条带版本。审计按这两个对 |
target_rtp | SMALLINT | 开局时目标 ×100(9750 = 97.5%) |
water_at | BIGINT | 开局时读到的水位快照 |
total_win | BIGINT | 各小局奖额之和,开奖时就定 |
jackpot_win | BIGINT | 其中爆池部分,0 为没爆 |
paid_win | BIGINT | 已 ack 入账的累计。每步 1010 入账后 +stepWin。转完 = total_win |
step_count | SMALLINT | 共几小局。含连消和免费旋转,一把可能几十步 |
free_spins | SMALLINT | 本把触发的免费旋转总次数(含再触发),0 为没触发。报表看触发率用 |
revealed_step | SMALLINT | 最后一次已推给客户端的小局。1010 只认 step == revealed_step。转完 = step_count − 1 |
status | TINYINT | 1 进行中 / 2 转完 / 3 超时结算 |
active | TINYINT 生成列 | IF(status = 1, 1, NULL)。UNIQUE(ns, uid, game_id, active):进行中的行 active=1 撞唯一键,插第二条未完局直接失败;转完变 NULL,唯一键不管 NULL,历史行随便多 |
created_at updated_at settled_at | DATETIME | 扣注时间、最后一次 ack 时间(超时任务按它找长期没动的进行中行)、转完时间 |
索引:UNIQUE(ns, uid, game_id, req_id);UNIQUE(ns, uid, game_id, active)(一人一款最多一条未完局,同时是 recover / 1011 查「有没有未完局」的主键读);(ns, game_id, created_at) 算 RTP、看表使用占比;(ns, uid, created_at) 玩家流水 / 回放列表;(ns, session_id) 下分时算本会话有效流水(返水);(status, updated_at) 超时任务扫进行中。
RTP 报表就是 SUM(total_win) / SUM(bet) 按 game_id、table_id 分组;水位曲线从这张表能重算。返水按 SUM(bet) 按 session / uid / 日。量大,按月分区。
5.2 小局表 round_step
大局是一把一笔注,不能拆。连消的每一步是小局,一步一行,永久保留。1011 开奖时和大局同事务把全部小局一次插进来(含还没揭示的),之后盘面只读,ack 只改 revealed_at / paid_at。ack、recover、超时结算、回放都从这里取,不现场再算。
| 字段 | 类型 | 说明 |
|---|---|---|
round_id step | BIGINT / SMALLINT,PK | 哪一把、第几步。step=0 停轮,之后按发生顺序编号,连消、免费旋转都往后排。WHERE round_id=? 就是这一大局全部小局 = 一次回放的全部素材 |
ns uid game_id | 冗余,按人按款查不用 JOIN | |
phase | TINYINT | 这一步是什么:1 主转 / 2 连消 / 3 免费旋转 / 4 免费旋转里的连消。客户端按它决定播哪套动画、要不要等玩家按键 |
free_no free_total | SMALLINT | 免费旋转第几次 / 共几次(含再触发追加后的总数),给客户端画「3 / 10」。非免费步为 0 |
grid | JSON | 本步盘面,符号 id 矩阵,行 × 列按游戏定义 |
wins | JSON | 本步中了什么:[{symbol, count, cells, win}]。cells 给客户端画连线/消除动画。没中为 [] |
multiplier | SMALLINT | 本步连消倍率 ×100 |
step_win | BIGINT | 本步赢分(已乘倍率)。大局 total_win = SUM(step_win) |
jackpot_win | BIGINT | 本步爆池部分,一般 0 |
has_next | TINYINT | 后面还有没有一步 |
revealed_at | DATETIME NULL | 推给客户端的时间。NULL = 还没揭示 |
paid_at | DATETIME NULL | 1010 入账时间。NULL = 还没入账。入账幂等看它:非空就不再加 |
paid_by | TINYINT | 1 玩家 ack / 2 超时结算 |
索引:PK(round_id, step);(ns, uid, game_id, round_id)。不按时间建索引,时间在大局上。
-1011 / -1010 的 data 就是这一行原样加 roundId 和余额,不另造结构;末步入账确认那条 -1010 只有 { roundId, balance, settled: true }:
{ "roundId": 90001, "step": 1, "phase": 2, "freeNo": 0, "freeTotal": 0, "grid": [["K","A","ox","coin","K"], ...], "wins": [{ "symbol": "ox", "count": 4, "cells": [[0,2],[1,1],[1,2],[2,2]], "win": 2500 }], "multiplier": 200, "stepWin": 5000, "hasNext": false, "balance": 123400 }
能回答的问题:这一把每一步转了什么、哪步给了多少、哪步是玩家 ack 的哪步是超时补的、揭示到入账隔了多久。玩家争议、按表复算 RTP、看连消长度分布都从这张表来。量 = 大局数 × 平均步数,按月分区。
5.2.1 回放
回放接口 GET /replay?roundId= 由大厅提供(非实时,走 HTTP,游戏服仍不对玩家开端口):读 round 一行 + round_step WHERE round_id=?,把每步按 -1011 / -1010 同样的 data 结构依次返回,客户端用播放 1011 / 1010 的同一套动画代码按顺序放。不需要另存回放数据,也不需要回放专用格式。列表页按 (ns, uid, created_at) 翻大局。只允许看自己的(校 uid),后台看任意。
5.2.2 1011 / 1010 / 1001 怎么读写
1011(uid + game_id 锁内):
- 读这人这款最新 session;不是 credited → 忽略,结束。
- 按
(ns, uid, game_id, req_id)读 round;有且 status=1 → 读 round_step(round_id, revealed_step) 重推,结束;有但 status≠1 → -1011settled带 wallet 余额,结束。 - 按
(ns, uid, game_id, active=1)读 round;有 → 拒局(-1011 带未完局的 roundId、revealedStep),结束。 - 开奖,一个事务:SELECT wallet FOR UPDATE(再校余额)+ INSERT round(status=1, revealed_step=0) + INSERT round_step × N(step 0 的 revealed_at=now) + UPDATE wallet −= bet + INSERT wallet_log(−201) + UPDATE jackpot_pool。任一失败整体回滚。
1010(uid + game_id 锁内,一个事务):
- 读最新 session;不是 credited(大厅正在下分 / 已下分)→ 忽略,钱不进已关的钱包。这一步在事务里、拿 wallet 行锁之后再读一次,和大厅下分的「先关门再扣光」严格串行。
- 按 active=1 读 round;没有 → 忽略。校
step == revealed_step;不等 → 忽略。 - 读 round_step(round_id, step);
paid_at非空 → 忽略(双保险)。 - 余额 += step_win;round_step.paid_at = now, paid_by = 1;round.paid_win += step_win, updated_at = now。
- has_next:读 round_step(round_id, step+1) 回给客户端,round.revealed_step = step+1,round_step(step+1).revealed_at = now。
- 没有 has_next:round.status=2、settled_at=now(active 自动变 NULL);player_game 累加;回
{roundId, balance, settled: true}。
1001 recover:读 session(非 credited 忽略);读 wallet 余额;读 jackpot_pool;按 (ns, uid, game_id, active=1) 读 round,有就读 round_step(round_id, revealed_step) 连同 round.req_id 放进 -1001。全部主键读。
「大局表」这个词在前面各章指的就是 round + round_step 两张合起来:大局是头,小局是每一步。
5.3 赔率表
两层。赔付倍数全款一份、玩家看得见、不随风格档位变;条带每张表一份、定概率、玩家看不见。赔付倍数存 game.paytable JSON 列(客户端说明页通过接口读它,不随包发);条带存 game_config.strips JSON 列,一款游戏的 6 张表放一份。两者都是游戏服启动加载进内存。
5.3.1 赔付倍数 game.paytable 字段格式
{
"gameId": "fortune_ox",
"version": 2,
"grid": { "rows": 3, "cols": 5 },
"symbols": ["wild", "scatter", "ox", "gold", "coin", "A", "K", "Q", "J"],
"pays": { // 倍数 ×100 存整数:2000 = 20 倍
"wild": { "3": 2000, "4": 5000, "5": 20000 },
"ox": { "3": 1000, "4": 2500, "5": 10000 },
"gold": { "3": 500, "4": 1200, "5": 4000 },
"coin": { "3": 200, "4": 500, "5": 1500 },
"A": { "3": 100, "4": 200, "5": 500 },
"K": { "3": 50, "4": 150, "5": 400 },
"Q": { "3": 50, "4": 100, "5": 300 },
"J": { "3": 50, "4": 100, "5": 300 }
},
"lines": 20, // 或 "ways": 243
"cascade": { "enabled": true, "multipliers": [1, 2, 3, 5] },
"feature": { "freeSpin": { "scatter": 3, "spins": 10 } },
"jackpot": { "contribution": 100, "trigger": { "symbol": "wild", "count": 5 } } // 万分比
}
客户端「i」说明页通过接口拿这份渲染,服务端算赢也用这份,两边一个来源。改倍数 = version +1 + 全部条带重新模拟 + 整份换 game_config.strips。
5.3.2 条带 game_config.strips 字段格式
一款游戏的全部条带放一份 JSON,按 style.tier 键分组,存在 game_config.strips。
{
"version": 3, // 条带版本,改任何一张就 +1
"paytableVersion": 2, // 绑定哪份赔付倍数
"tables": {
"calm.low": { "reels": [ ... 5 轴 ... ], "cascadeReels": [ ... ], "sim": { ... } },
"calm.mid": { "reels": [ ... ], "cascadeReels": [ ... ], "sim": { ... } },
"calm.high": { "reels": [ ... ], "cascadeReels": [ ... ], "sim": { ... } },
"hot.low": { "reels": [ ... ], "cascadeReels": [ ... ], "sim": { ... } },
"hot.mid": { "reels": [ ... ], "cascadeReels": [ ... ], "sim": { ... } },
"hot.high": {
"reels": [
["K","A","coin","K","gold","A","coin","ox","wild","K","A","coin","scatter","K","gold","A","coin","ox","K","A"],
["A","K","coin","gold","K","A","ox","coin","K","wild","coin","K","gold","A","scatter","K","A","ox","coin","K"],
["coin","K","A","K","gold","coin","A","ox","coin","wild","A","K","coin","gold","scatter","K","coin","ox","A","K"],
["K","coin","A","gold","K","coin","A","K","ox","coin","wild","A","K","scatter","gold","A","coin","K","ox","A"],
["A","K","gold","coin","A","K","ox","A","coin","K","wild","gold","A","K","coin","scatter","K","A","ox","coin"]
],
"cascadeReels": [ ... 消除后补落用的条带,可与 reels 相同 ... ],
"freeReels": [ ... 免费旋转用的条带,没有则用 reels ... ],
"sim": { "spins": 1000000, "rtp": 9803, "hitRate": 2310, "cascadeRate": 1870, "avgCascade": 260, "freeRate": 85, "bigWinPer": 41200, "maxWinX": 180000 }
}
}
}
数学组交付的就是这份 JSON。6 张 × 5 轴 × 几十格,几十 KB,游戏服启动 / 收到刷新通知时读一次进内存,每把不查。抽奖:每个轮子在自己的 reels[i] 上随机取起点,连续取 rows 格。sim 是数学组交付时跑出来的指标(×100 整数),验收对着 3.10 看;选档时按各表 sim.rtp 排低 / 中 / 高。
改条带 = 整份换、version +1。大局记 table_id(如 hot.high)+ strips_version。进行中的局仍按内存里开局时的那份算。旧版本条带数据库不留,要追溯就靠数学组交付时的 JSON 归档(配置仓 / 对象存储按 version 存一份)。
5.4 游戏表与游戏配置表
两张。game 放一款游戏的固有属性(名字、盘面、注额档、赔付倍数),全局一行,改动走审核;game_config 放条带和运营参数(6 张条带、目标 RTP、风格、开关),按商户按款一行,ns=0 是默认行。游戏服两张都每把读(进程缓存 1 秒;strips 列只在版本变了才重新加载)。
5.4.1 游戏表 game
| 字段 | 类型 | 说明 |
|---|---|---|
game_id | VARCHAR(32) PK | 如 fortune_ox |
name | JSON | 多语言展示名 {"zh":"财运牛","id":"Sapi Rezeki"} |
rows cols | TINYINT | 盘面尺寸 |
orientation | TINYINT | 1 竖版 / 2 横版。/launch 随 gameUrl 回给商家,客户端进游戏前锁屏幕方向;游戏列表按它分组展示 |
bet_options | JSON | 可选注额档,库存整数 [1000, 5000, 10000, 50000]。随 -1001 betOptions 带给客户端(-1002 是踢人,不带它) |
min_bet max_bet | BIGINT | 1011 校验 bet 在区间内且在 bet_options 里 |
paytable | JSON | 赔付倍数,格式见 5.3.1。paytable.version 与 game_config.strips.paytableVersion 对应 |
status | TINYINT | 0 下架 / 1 上架。下架的不出现在大厅列表 |
created_at updated_at | DATETIME |
改 paytable 走审核。旧版本数据库不留,和条带一样靠归档。
5.4.2 游戏配置表 game_config
| 字段 | 类型 | 说明 |
|---|---|---|
ns game_id | PK | 按商户按款一行;商户没单独配就用 ns=0 的默认行 |
strips | JSON | 这款游戏的 6 张条带,格式见 5.3.2。ns>0 行留 NULL 表示用 ns=0 的条带;填了就是该商户专用的一套数学 |
strips_version | INT | = strips.version,拆出来方便查和比对;大局记的就是它 |
target_rtp | SMALLINT | ×100,与 sim.rtp 同精度。可填区间 = [该 style 最低档 sim.rtp, 最高档 sim.rtp + 返水上限] |
style | VARCHAR(16) | 运营选的风格 |
water_threshold | BIGINT | 切档阈值,库存整数(按多少倍平均注换算后填) |
rebate_rate | SMALLINT | 返水 ×100,系统按目标和最高档算出,运营只看 |
enabled | TINYINT | 0 维护中,1011 回拒局 |
updated_by updated_at |
后台两个页面:「游戏管理」改 game 和 game_config.strips(技术 / 数学,走审核);「运营配置」改 game_config 其余列(运营,随时)。谁最后改的看 updated_by / updated_at。
水位不在这张表。水位在 Redis:water:{ns}:{game_id}(共用默认配置的商户都记在 ns=0 那一份),INCRBY 库存整数;定时任务每分钟落一条 water_snapshot(5.4.4)画曲线、重启恢复。Redis 丢了从大局表重算:Σ bet × target_rtp / 10000 − Σ total_win。
5.4.3 奖池 jackpot_pool
奖池是钱,不是信号,所以不放 Redis、进 1011 事务。一款(按 ns)一行。
| 字段 | 类型 | 说明 |
|---|---|---|
ns game_id | PK | 水位一样:共用默认配置的记 ns=0 |
amount | BIGINT | 当前池,库存整数。-1101 / -1001 里的 jackpot 就是它 |
seed | BIGINT | 爆池后重置到的起始额(运营配,可为 0) |
contributed_total paid_total | BIGINT | 累计注资 / 累计爆出。seed × 爆池次数 + contributed_total − paid_total = amount,对不上就是绕过事务改了 |
hit_count last_hit_at | INT / DATETIME | 爆池次数 / 最近一次 |
updated_at |
1011 事务里:UPDATE jackpot_pool SET amount = amount + bet × contribution / 10000 − jackpot_win, contributed_total += …, paid_total += jackpot_win;爆了再 amount += seed、hit_count += 1。行锁天然串行,一款每秒几十把没有压力。round.jackpot_win 与 wallet_log 202 之和必须等于 paid_total。
5.4.4 水位快照 water_snapshot
| 字段 | 类型 | 说明 |
|---|---|---|
id | BIGINT PK | |
ns game_id | 对应 Redis key | |
water | BIGINT | 快照时 Redis 值 |
table_id | VARCHAR(16) | 快照时按阈值会选到的表,画「档位切换」曲线用 |
at | DATETIME | 索引 (ns, game_id, at) |
每分钟一行,只插不改;重启时取最新一行回填 Redis,再用之后的大局补差。保留 90 天。
5.5 上下分表
三张:上分单 session(一次进游戏一行,会话状态机就在这行上)、上下分流水 transfer(每次和商家的钱来钱往一行,幂等和对账)、游戏余额 wallet(一人一款一行)。
5.5.1 上分单 session
connect proxy 决定要上分时插一行(先 pending,商家扣完补 credited)。挤号、轮询任务、下分、超时结算都只认这一行。状态只能用条件更新往前走。/launch 取地址不插行。
| 字段 | 类型 | 说明 |
|---|---|---|
id | BIGINT PK | session_id。大局表 session_id 指它 |
ns uid game_id | ||
amount_in | BIGINT NULL | 上分金额(商家按余额全部扣走的数,可为 0)。pending 时为空 |
status | TINYINT | 0 pending / 1 credited / 2 cashing_out / 3 cashed_out / 4 failed。见状态机 |
jti | VARCHAR(64) | 当前连接用的 JWT id。挤号时覆盖。同 jti 再连(3 秒内重连)直接放行;不同 jti 连进来视为换设备,覆盖并踢旧连接 |
client | VARCHAR(64) | 当前 CF 连接的 client id。挤号 disconnect 时 whitelist 新的、断旧的 |
connected_at | DATETIME NULL | connect proxy 放行时写。轮询只看 credited 且非空的行 |
missing_since | DATETIME NULL | 轮询首次发现不在 presence 的时刻。在了清空。≥ 3 秒触发下分 |
cashout_amount | BIGINT | 下分时扣走的空闲分(0 = 钱都在未完局里) |
cashout_at | DATETIME NULL | cashed_out 时间。未完局超时任务按它算「已下分多久」 |
created_at updated_at |
索引:(ns, uid, game_id, status) connect proxy 查「这人这款最新一行」、挤号、超时任务都用;(status, missing_since) 3 秒任务;(status, created_at) 扫 pending 超 10 分钟。不再需要 req_id(幂等键在 transfer.id)、url_issued_at(不动钱不用退)、source(只有一种来源)。
状态机(全部条件更新,WHERE status = 旧值,影响 0 行就是别人先到了):
| 从 | 到 | 谁 | 条件 |
|---|---|---|---|
| — | pending | connect proxy | 最新 session 是 cashed_out / failed / 没有;事务一和 transfer(status=0) 一起插 |
| pending | cashing_out | 对账任务 | pending 超 10 分钟、商家说已扣:补事务二把钱进 wallet 后立刻转下分退回(人不在线,不 disconnect)。与 connect proxy 抢 lock:connect |
| pending | credited | connect proxy | 商家扣款返回成功(含 amount=0 且有未完局)。事务二 |
| pending | failed | connect proxy / 对账任务 | 商家明确失败;或 amount=0 且无未完局;或对账查到没扣 |
| pending | pending | connect proxy | 商家超时。transfer status=3,下次连上用同一 transfer.id 再问 |
| credited | credited | connect proxy 挤号 | 不换状态,覆盖 jti / client、清 missing_since,publish -1002 + disconnect(whitelist 新 client) |
| credited | cashing_out | 轮询任务 | missing_since ≥ 3 秒且本轮不在 presence |
| cashing_out | cashed_out | 轮询任务 | disconnect → 扣空闲分 → 商家加款都成功后 |
| cashing_out | cashing_out | 轮询任务 | 中途失败(商家加款超时):留在 cashing_out,下一轮重试,靠 transfer 幂等不重复加款 |
| cashed_out | cashing_out | 超时结算任务 | 未完局超时(4.3):剩余步派彩进 wallet 后,把这张已下分的单子再拉回 cashing_out,走一次下分(新插一条 direction=2 的 transfer 挂原 session_id),完了回 cashed_out。拿 lock:connect,人此刻连进来会被拒 retry |
同一 (ns, uid, game_id) 同一时刻最多一行 pending / credited / cashing_out(connect proxy 靠 Redis 锁 + 条件插入保证)。connect proxy 见 credited 走挤号不插新行;见 cashing_out 拒 retry;见 pending 补问商家;见 cashed_out / failed 插新行。任务触发的两条「再下分」(pending → cashing_out、cashed_out → cashing_out)都要先拿 lock:connect,保证和 connect proxy 不同时动同一张单。
5.5.2 上下分流水 transfer
每次和商家的钱来钱往一行,先插流水再调商家。商家接口的幂等键就是这行的 id。上分、下分方向都是我们主动调商家。
| 字段 | 类型 | 说明 |
|---|---|---|
id | BIGINT PK | 调商家时当 req_id 传过去,商家按它幂等 |
ns uid game_id session_id | ||
direction | TINYINT | 1 上分(商家 → 游戏,connect proxy 里)/ 2 下分(游戏 → 商家,轮询任务里) |
amount | BIGINT NULL | 带符号,站在游戏钱包看:上分为正、下分为负。上分调商家前为空,商家返回后填;和 wallet_log 同一行同一个数 |
merchant_tx_id | VARCHAR(64) | 商家返回的流水号 |
status | TINYINT | 0 处理中 / 1 成功 / 2 失败 / 3 未知(超时,待对账) |
error | VARCHAR(255) | 商家返回的错误 |
created_at finished_at |
索引:(ns, session_id);(status, created_at) 对账任务扫「处理中 / 未知」。重试都用同一个 id再调商家,不插新行:上分找 session 的 direction=1 且 status∈{0,3} 那行;下分找该 session 最新一条 direction=2 且 status∈{0,3} 的行(一张 session 正常只有一条下分;超时结算再下一次会多一条)。商家侧同 id 只扣 / 只加一次。
没有冲正:商家扣成功而我们失败的,session 留 pending,下次连上或对账任务用同一 id 再问,商家返回已扣的结果,我们补账。商家侧永远不需要「把扣掉的退回去」这条路。
5.5.3 游戏余额 wallet
| 字段 | 类型 | 说明 |
|---|---|---|
ns uid game_id | PK | 一人一款一行 |
balance | BIGINT | 可下注的游戏分 = 空闲分。未 ack 的连消赢分不在这里,在 round_step 里(paid_at 为空的那些步) |
version | INT | 乐观锁,或直接靠 uid+game_id 行锁 |
updated_at |
改它的只有五处:大厅上分加(connect proxy)、游戏服 1011 扣注、游戏服 1010 加 stepWin(含爆池;超时任务 paid_by=2 补派彩也算这一类)、大厅下分扣光、后台运营科目(赠送 / 调账)。大厅和游戏服都直接写这张表,互不调用;互斥全靠这一行的行锁:改余额一律 SELECT ... FOR UPDATE 或原子 UPDATE balance = balance ± ?,禁止先读再在应用层算好回写。返水不进游戏分,按 3.6 由商家直接发到真钱账户。每改一次同事务插一行 wallet_log,别处不再单独记 before/after。下分 = SELECT ... FOR UPDATE 取旧值再 UPDATE wallet SET balance = 0,扣走的数写进 transfer 和 session.cashout_amount;游戏服的 1010 在拿到行锁后再读一次 session,见 cashing_out / cashed_out 就放弃,所以下分之后不会再有钱进来。
5.5.4 余额流水 wallet_log
wallet.balance 每变一次一行,和改余额的那条 UPDATE 同事务。这是唯一一份逐笔账:任何时刻的余额 = 上一行 balance_after;SUM(amount) 按 uid 必须等于当前 balance,对不上就是有地方绕过它改了钱。
| 字段 | 类型 | 说明 |
|---|---|---|
id | BIGINT PK | |
ns uid game_id | ||
subject_code | SMALLINT | 交易科目,= 5.5.5 subject.code。正数加钱、负数减钱,和 amount 同号;三位数,百位 = 类别;成对的科目数字一样正负相反(101 上分 / −101 下分,201 派彩 / −201 扣注),和事件 code 一个习惯 |
amount | BIGINT | 带符号:正数加钱、负数减钱。扣注 −1000、派彩 +2500。SUM(amount) 就是余额,不用按科目翻符号 |
balance_before balance_after | BIGINT | after = before + amount |
ref_id ref_step | BIGINT / SMALLINT(与 round_step.step 同型) | 指向来源:扣注 = round_id;派彩 = round_id + step;上分 / 下分 = transfer.id;返水 = session_id;调账 = 后台单号 |
session_id | BIGINT | 发生在哪次会话里,下分对账按它汇总 |
created_at | DATETIME |
索引:(ns, uid, game_id, id) 玩家账单翻页;(ns, session_id) 一次会话的全部进出;(ns, subject_code, created_at) 按科目日汇总。只插不改不删,量 = 大局数 × (1 + 平均步数) 再加上下分,按月分区。
有了它,round / transfer 上的 balance_before / balance_after 去掉,不留两份。玩家「我的钱怎么变成这样的」、客服查账、日终 wallet 总额对账都只看这张表。
5.5.5 交易科目 subject
wallet_log 每一行属于哪个科目。科目是字典表,全局一份、不带 ns,上线时灌好,加新科目插一行不改代码枚举。报表按科目分组,返水按科目的 in_turnover 挑流水,客户端账单按 show_player 过滤。
code 是带符号的三位数:正数加钱、负数减钱;百位是类别(1 商家往来 / 2 游戏 / 3 运营),后两位在类别内编号;有来有回的一对用同一个数字正负相反。写 wallet_log 时校 sign(amount) == sign(subject_code),不等直接拒,不用另设方向字段。
| 字段 | 类型 | 说明 |
|---|---|---|
code | SMALLINT PK | wallet_log.subject_code 指它。三位数,符号 = 钱的方向,百位 = 类别 |
key | VARCHAR(32) UNIQUE | 代码里按它引用,不在代码里写数字:deposit withdraw payout bet … |
name | JSON | 多语言名,客户端账单和后台报表显示 |
category | TINYINT | = ABS(code) DIV 100。1 商家往来(上分 / 下分)/ 2 游戏(扣注 / 派彩 / 爆池)/ 3 运营(返水 / 赠送 / 调账)。日终按它三段对账:商家往来净额 = 游戏净额 + 运营净额 + 余额变化 |
in_turnover | TINYINT | 算不算有效流水。只有 bet 为 1,返水按它挑 |
show_player | TINYINT | 玩家账单里显示不显示。调账、内部冲正为 0 |
status | TINYINT | 0 停用(历史行仍能显示)/ 1 启用 |
初始科目:
| code | key | 类别 | 流水 | 来源 ref |
|---|---|---|---|---|
| 101 | deposit 上分 | 商家往来 | 否 | transfer.id |
| −101 | withdraw 下分 | 商家往来 | 否 | transfer.id |
| 201 | payout 派彩 | 游戏 | 否 | round_id + step |
| −201 | bet 扣注 | 游戏 | 是 | round_id |
| 202 | jackpot 爆池 | 游戏 | 否 | round_id + step。和 payout 分开记,奖池对账用 |
| 301 | rebate 返水 | 运营 | 否 | session_id。预留:默认返水由商家发真钱(3.6),不进游戏分、不产生这行;商户要求返水进游戏分时才用 |
| 302 | bonus 赠送 | 运营 | 否 | 活动单号 |
| 303 | adjust_in 调账加 | 运营 | 否 | 后台工单号 |
| −303 | adjust_out 调账扣 | 运营 | 否 | 后台工单号 |
101 / −101 是商家来回,201 / −201 是一把里的进出,303 / −303 是后台手工加减。202、301、302 只进不出,没有负数对。新科目在所属类别里往后编。
爆池从派彩里拆出来是因为奖池是另一笔钱:每把从注里抽 jackpot.contribution 进池,爆了从池里出。round_step 上 jackpot_win 那部分走 jackpot,其余走 payout,一步可能两行。
5.6 用户表
玩家是商家的用户,我们不做注册登录。商家第一次上分带 merchant_uid 过来,没有就建一行,有就更新资料。我们内部一律用自己的 uid,JWT sub、频道名、所有业务表都是它,商家 id 只在边界上换一次。
5.6.1 用户 player(user 是保留字,不用)
| 字段 | 类型 | 说明 |
|---|---|---|
uid | BIGINT PK | 我们的 id。自增或雪花。全站唯一,跨商家不重复 |
ns | INT | 属于哪个商家 |
merchant_uid | VARCHAR(64) | 商家侧用户 id。UNIQUE(ns, merchant_uid)。上分时按这两键 upsert |
nickname avatar | VARCHAR | 商家上分时带来,可空。只用于排行榜/大奖播报展示 |
currency | CHAR(3) | IDR / MXN。跟商家走,一个用户一种,不换 |
lang | VARCHAR(8) | 客户端文案语言,商家带或按 ns 默认 |
vip_level | TINYINT | 商家带来。只影响返水档,不影响任何一张表、不影响水位 |
status | TINYINT | 1 正常 / 2 冻结。冻结:上分拒、connect proxy 拒、1011 拒局,已在局内的 ack 照常(把已抽的播完),不下分 |
risk_tag | VARCHAR(32) | 风控标记(脚本、多账号等),只标不动盘 |
first_seen_at | DATETIME | 第一次上分 |
last_seen_at | DATETIME | 最近一次 connect proxy 放行 |
created_at updated_at |
索引:UNIQUE(ns, merchant_uid);(ns, status)。不存密码、手机、邮箱、真钱余额——都是商家的事。
5.6.2 用户游戏统计 player_game
一人一款一行,每把 1011 转完后累加。给返水、风控、后台看人用,不参与抽奖。
| 字段 | 类型 | 说明 |
|---|---|---|
ns uid game_id | PK | |
spins | INT | 累计把数。若将来做新手期,选表条件看它 |
total_bet total_win | BIGINT | 累计下注 / 派彩。个人 RTP = total_win / total_bet,只给后台看 |
max_win max_win_x | BIGINT / INT | 单把最大赢、最大倍数。大奖播报和风控用 |
jackpot_hits | INT | 爆池次数 |
sessions | INT | 上分次数 |
first_played_at last_played_at | DATETIME |
更新时机:大局 status 变 2/3 时 UPDATE ... SET spins=spins+1, total_bet=total_bet+?, total_win=total_win+?,和大局结算同事务。不从大局表实时聚合,那张表会很大。
禁止:任何选表、抽奖、水位逻辑读这张表里的个人输赢。3.2「不按人改权重」在这里落地——代码层面抽奖函数拿不到 uid 维度的统计。
5.6.3 和商家的边界
| 数据 | 归谁 |
|---|---|
| 账号、密码、KYC、真钱余额、充提 | 商家 |
| merchant_uid → uid 映射、昵称头像、VIP 档、冻结 | 我们,player |
| 游戏分、大局、小局、上下分流水 | 我们,5.1~5.5 |
| 返水计算 | 我们算(按 bet 流水 + vip_level),钱由商家发 |
商家取地址接口 /launch 的用户字段:merchant_uid(必)、nickname avatar vip_level lang(可选,带了就覆盖)。冻结用户由商家调我们的「冻结」接口,或我们风控自己标。
5.7 几条关系
- 大局 round_id 一把一号,永久保留。小局按 (round_id, step) 挂在下面。一人一款最多一条 status=1 的大局,靠生成列 active 的唯一键保证。
- 1011 事务(游戏服):校 session credited + SELECT wallet FOR UPDATE + INSERT round + INSERT round_step × N + UPDATE wallet + INSERT wallet_log(bet) + UPDATE jackpot_pool。任一失败整体回滚。
- 1010 事务(游戏服):SELECT wallet FOR UPDATE + 再读 session(非 credited 回滚放弃)+ UPDATE wallet + INSERT wallet_log(payout[, jackpot], round_id, step) + UPDATE round_step(paid_at, 下一步 revealed_at) + UPDATE round(paid_win, revealed_step) 或 round(status=2, settled_at)。
- 超时结算(大厅任务):拿 lock:connect → round_step 里 paid_at IS NULL 的各步逐步派彩进 wallet(每步一行 wallet_log payout,paid_at=now, paid_by=2)→ round status=3 → 条件更新 session cashed_out → cashing_out → 再走一次正常下分(wallet_log withdraw + 新 transfer 挂原 session_id)→ cashed_out。钱不绕过 wallet 直接给商家,账才连得上。
- 上分(connect proxy 里):事务一 INSERT session(pending) + INSERT transfer(status=0) → 调商家扣款(req_id=transfer.id)→ 事务二 UPDATE transfer(status=1, amount) + UPDATE wallet + INSERT wallet_log(deposit) + UPDATE session(credited) + player_game.sessions+1。player 在 /launch 时 UPSERT。
- 大局结算(转完 / 超时):UPDATE round + UPDATE player_game 累加,同事务。
- 下分(大厅任务,不经游戏服):条件更新 session → cashing_out;disconnect;事务内 SELECT wallet FOR UPDATE + UPDATE wallet.balance=0 + INSERT wallet_log(withdraw) + INSERT transfer(direction=2, status=0);调商家;成功后 transfer status=1、session → cashed_out。商家超时留 status=3 待对账,下一轮同 id 重试。
- 条带存 game_config.strips,改条带 = 整份换、version +1。改条带不发版;改倍数改 game.paytable 走审核,客户端说明页从接口读、也不发版。
- 大厅和游戏服之间没有一条箭头:大厅写 session / transfer / wallet(上下分),游戏服写 round / round_step / wallet / jackpot_pool(局内),两边只在 wallet 这一行相遇,靠行锁 + session 状态协作。
6. 四块怎么咬在一起
完整顺序见 第 0 章。这里只收一句:改钱的只有上分、下分、扣注、派彩、返水。前四项要 req_id / round_id;返水跟流水对账。大局表永久保留,转完改状态,一人一款最多一条进行中。
7. 建议完善顺序
- 先定谁做轮带。这是游戏数学的活,不是程序能顺手做的。有游戏数学师就他做;没有,第一款游戏买或外包数学包(1 份赔付表 + 6 份条带 + 每份的百万把模拟报告),拿到范本。
- 同时自己写模拟工具:输入符号表、赔付倍数、连消规则、目标指标(RTP、命中率、连消触发率、大奖频率),随机生成条带 → 百万把模拟 → 对比目标 → 调格数 → 循环到进容差,输出条带 JSON + 报告,写进 game_config.strips。验收外包的表也用它复算。第二款起照范本指标自己排。
- 拿到 6 张表后跑「水位选表」收敛模拟;后台先能填目标 RTP、选风格。
- 产品补第 3.9 节:切表阈值、返水上限、1011 耗时上限。
- -1011 / -1010 盘面字段;未完连消超时时长。