老虎机开发方案

独立新项目。先看 完整流程:上分进游戏、spin、水位 RTP、大局表、正常转、断线重连、断开下分。商家 /launch 只取地址,上分在连上 CF 时由大厅在 connect proxy 里调商家全额拉;大厅、游戏服都无状态、互不调用,只共享 DB / Redis,只有 Centrifugo 持有连接;掉线靠轮询 presence;局内走 user,全款广播(奖池等)走游戏频道 slot:{gameId},按 code 分类型。

版本:v1.17 状态:大厅与游戏服零调用,只共享 DB / Redis;wallet 统一行锁;1011 / 1010 校 session;同 reqId 打到已转完的局回 settled;奖池落 jackpot_pool;状态机补任务触发的再下分;RTP 统一 ×100;商家接口三类结果 + 退避重试 + 熔断(2.6);wallet / session 改为一人一行,换游戏 = 挤号不动钱;新增 3.11 条带生成工具、3.12 RTP 同事干活流程

序号章节这一节要钉死什么状态
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 继续 与下分对齐

目录

  1. 完整流程
  2. 游戏通信
  3. 上下分
  4. RTP:水位选表与返水
  5. 断线
  6. 数据表设计
  7. 四块怎么咬在一起
  8. 建议完善顺序

0. 完整流程

下面按一条线写:上分 → 进游戏 → spin → 预算 RTP → 存大局表 → 正常转 → 断线重连 → 断开下分。细则仍在后面各章,这里只把已经钉死的顺序说清楚。

0.0 时序图

六条泳道:客户端、商家、大厅、Centrifugo、游戏服、DB / Redis。实线是请求,虚线是回包 / 推送;红色条是分支 / 拒绝路径,灰色条是补充说明。每张图都是一条完整链路,字段名与 5. 数据表、code 与 1.3.2 事件 code 一一对应。第 234 章各自另有一张按判断分支画的流程图(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 或 failed 新上分);新上分 = 事务一插 pending → 调商家全额扣 → 事务二 wallet+ 补 credited;订阅由服务端下发;连上先 1001。

A. 取地址 + 连上即上分(商家 /launch 不动钱 → 连 CF → connect proxy 里大厅调商家全额扣 → 1001) 客户端 商家 大厅 Centrifugo 游戏服 DB / Redis 玩家在商家页面点这款游戏 点游戏 POST /launch {merchant_uid, gameId, req_id, nickname, avatar, vip_level, lang} 商家签名。不带金额 校 game.status 上架 · player.status 未冻结;UPSERT player(ns, merchant_uid) → uid 签名不对 / 游戏下架 / 玩家冻结 → 回错,无地址。这一步没有任何钱的动作,重复调无副作用 签 JWT {sub: uid, gameId, jti: 随机, exp: 24h};不插 session、不动 wallet、不调商家扣款 200 {gameUrl: https://…/play?token=JWT, orientation} gameUrl 交给玩家(跳转 / 内嵌) 第一次进、下分后再进、换设备进都是这一条。同一人重复取每次新 jti,旧 token 到期前也能连,谁先连谁算 WebSocket connect(token=JWT) connect proxy POST /internal/cf/connect {user, client, token claims} 超时配 5 秒 验签、验 exp;拿 uid / gameId;读 player.status 冻结 → 拒 denied SETNX lock:connect:{ns}:{uid} TTL 6s(一人一把,不分款) 锁没拿到(同一人两条连接同时进)→ 拒 {reason: retry},客户端 1 秒后重连。下面所有放行 / 拒绝路径返回前都释放锁 SELECT session WHERE ns, uid ORDER BY id DESC LIMIT 1(不带 game_id,一人一张单)→ 按状态分四路 ① credited(3 秒内重连 / 换设备 / 从别款切过来):不动钱。API publish -1002 到旧款 user 频道;disconnect(user=uid, whitelist=[新 client]);UPDATE session SET game_id=新, jti=新, client=新, missing_since=NULL, connected_at=now → 放 ② cashing_out(正在下分)→ 拒 {reason: retry};下分几百毫秒完,客户端重连落到 ④ ③ pending(上次调商家没结果)→ 用这行的 transfer.id 再调商家扣款(幂等返回同一结果)→ 成功走事务二放行;又未知拒 retry;业务拒绝 → session failed → 拒 denied。商家熔断中直接拒 retry ④ cashed_out / failed / 没有 → 新上分,下面 3 步 事务一:INSERT session(status=pending, game_id, jti, client) · INSERT transfer(direction=1, amount=NULL, status=0, session_id);COMMIT POST 扣款 {merchant_uid, game_id, req_id: transfer.id, ts, sign} 超时 3 秒。商家按该用户余额全部扣走,不传金额 200 {amount: 库存整数(0 也是成功), merchant_tx_id, balance_after} 同 req_id 重复调必须返回第一次的结果 校 amount:非负整数、不超过单次上限。不核对商家余额,对账按 req_id 对流水 未知(超时 / 5xx / 验签不过)→ transfer status=3、session 留 pending → 拒 retry,不冲正,下次连上按 ③ 用同一 transfer.id 再问;10 分钟后对账任务按退避先查再问(2.6)。业务拒绝 → transfer status=2、session failed → 拒 denied amount = 0 时:SELECT round WHERE ns, uid, active=1(任一款有未完局就放行) amount = 0 且无未完局 → transfer status=1 amount=0、session failed → 拒 {reason: no_balance},提示回商家充值 事务二:UPDATE transfer SET status=1, amount, merchant_tx_id · UPDATE wallet SET balance += amount · INSERT wallet_log(101, +amount, before, after, ref=transfer.id) · UPDATE session SET status=credited, amount_in=amount, connected_at=now · UPDATE player_game sessions+1;COMMIT 事务二失败(商家已扣)→ session 仍 pending、transfer 仍 0/3 → 拒 retry;下次连上按 ③ 再问商家,返回已扣的同一结果,补做事务二。钱不丢 200 {channels: [slot:{gameId}:user:{uid}, slot:{gameId}]} 服务端替他订,客户端不 subscribe;释放锁 connected + 两条频道已订(都不开 history;当前奖池、注额档由下面的 -1001 带回) publish 1001 {} 到 slot:{gameId}:user:{uid}(每次连上固定先发) publish proxy POST {user, channel, data:{code:1001}} 任意一台游戏服 校 channel 里的 gameId / uid == proxy user;code 必须正数;取 uid+gameId 锁 读 session 最新一行(非 credited → 忽略);读 wallet.balance · jackpot_pool.amount · game.bet_options ;读 round(ns, uid, gameId, active=1);有则读 round_step(round_id, revealed_step) API publish -1001 {balance, jackpot, betOptions, round: null | {roundId, reqId, step, phase, freeNo, freeTotal, grid, wins, multiplier, stepWin, hasNext}} -1001 → 客户端以它为准渲染余额;round 非空则从该步继续播、发 1010;null 则可 1011 proxy 放行后原 1001 回声也会到客户端 → 丢弃(1011 / 1010 同理)

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 带余额,不重推盘面。

B. spin:1011 → 幂等 / 拒局检查 → 水位选表 → 抽 1 条完整大局 → 事务落库 → -1011 客户端 商家 大厅 Centrifugo 游戏服 DB / Redis 前提:没有进行中大局(上一把 hasNext=false 已收到余额)。玩家选注额(bet_options 内)按旋转,转轮先空转 publish 1011 {reqId: 客户端生成唯一号, bet} publish proxy {user, channel, data:{code:1011, reqId, bet}} 取 uid+gameId 锁(Redis,只串行同一人的消息,不管钱);校 channel 与 user 一致;读 session 最新一行 ,非 credited 或 game_id ≠ 这款 → 忽略 查 round(ns, uid, gameId, req_id) 有这行(重发 / 网络卡)→ status=1:读 round_step(revealed_step) 重推(step=0 用 -1011,否则 -1010);status≠1(已转完):-1011 {reqId, reason: settled, balance},不重推盘面 → 结束。不扣注、不抽、不动水位 查 round(ns, uid, gameId, active=1) 有进行中大局且 reqId 不同 → API publish -1011 {reqId, reason: round_in_progress, roundId, revealedStep} → 结束 读 game(status, bet_options, min_bet, max_bet) · game_config(enabled, style, target_rtp, water_threshold, strips_version)(进程缓存 1 秒)· player.status game 下架 / enabled=0 / bet 不在 bet_options 或超区间 / 玩家冻结 → -1011 {reqId, reason} 拒局 → 结束 读 wallet.balance balance < bet → -1011 {reqId, reason: insufficient_balance, balance} → 结束 GET water:{ns}:{gameId}(Redis,快照不锁) tier = 水位 > +阈值 ? high : 水位 < −阈值 ? low : mid;table_id = style + '.' + tier;取内存里该 strips_version 的条带 抽:主转 reels → grid → 按 game.paytable 算赢(线 / ways)→ 有消除则 cascadeReels 补落再算 → … → scatter ≥ 触发数 → 免费 N 次 freeReels → 免费里再触发追加(到上限停)→ 爆池判定 → 每步一条 {phase, grid, wins, multiplier, stepWin, jackpotWin, hasNext},total_win = Σ stepWin。不筛、不重抽 BEGIN;SELECT wallet FOR UPDATE;SELECT session FOR SHARE 再读一次,非 credited → ROLLBACK 忽略;再校 balance ≥ bet,不够 ROLLBACK 拒局 INSERT round(round_id, ns, uid, game_id, req_id, session_id, bet, table_id, strips_version, target_rtp, water_at, total_win, jackpot_win, step_count, free_spins, revealed_step=0, status=1) INSERT round_step × N(step 0 的 revealed_at = now,其余 NULL)· UPDATE wallet −= bet · INSERT wallet_log(−201, −bet, before, after, ref=round_id) · UPDATE jackpot_pool amount += bet × contribution/10000 − jackpot_win(爆了再 += seed, hit_count+1 )· COMMIT COMMIT 失败 → 整体回滚,注没扣、表没行 → -1011 {reqId, reason: retry};客户端用原 reqId 重发即可。req_id 唯一键冲突 = 并发重发,回到第 5 步重推 事务外:INCRBY water:{ns}:{gameId} += bet × target_rtp/10000 − total_win(不锁,只是选表信号) 奖池已在事务里改完(jackpot_pool 是钱,不进 Redis)。-1101 节流:每款每秒最多 1 条,爆池立刻 API publish -1011 {roundId, reqId, step:0, phase:1, grid, wins, multiplier, stepWin, hasNext, freeTotal, balance:扣后余额} 到 user 频道 API publish -1101 {amount: 当前奖池} 到 slot:{gameId}(全款在线的人都收到) 释放锁 -1011 → 停轮、播 step 0(中了 scatter 弹「获得 N 次免费」)。hasNext=true 播完自动发 1010;false 播完发 1010 等余额

C. 正常转(1010 → -1010)

对应 0.4。要点:游戏服只从 round_step 取,不重算;session 非 credited(已下分 / 正在下分)、旧步 / 越步 / 已入账一律忽略;本步 step_win 入账(201 / 202)与揭示下一步同一事务;末步把 round 置 2、累 player_game;-1010 末步不带盘面只带余额,settled: true

C. 正常转:每步一个 1010,游戏服只从 round_step 取,逐步入账 客户端 商家 大厅 Centrifugo 游戏服 DB / Redis 播完当前 step。phase 2/4 连消:自动发 1010;phase 3 免费旋转:等玩家按旋转(或自动模式)再播、再发 1010 publish 1010 {roundId, step: 当前已播完的步} publish proxy {user, channel, data:{code:1010, roundId, step}} 取 uid+gameId 锁(只串行消息) 读 session 最新一行 → 非 credited(大厅正在下分 / 已下分)→ 忽略,钱不进已关的钱包 读 round(ns, uid, gameId, active=1) 没有进行中大局 / roundId 不等 → 忽略,不回。step < revealed_step(旧 ack 重发)→ 忽略,可把当前步再推一次。step > revealed_step → 忽略 读 round_step(round_id, step) paid_at 非空(已入账过)→ 忽略,双保险 BEGIN;SELECT wallet FOR UPDATE(与大厅下分同一行锁);SELECT session FOR SHARE 再读一次,非 credited → ROLLBACK 放弃;UPDATE wallet += step_win;INSERT wallet_log(201 payout, +(step_win − jackpot_win), ref=round_id, ref_step=step) jackpot_win > 0 → 再 INSERT wallet_log(202 jackpot, +jackpot_win);UPDATE round_step paid_at=now, paid_by=1;UPDATE round paid_win += step_win, updated_at=now has_next = true(还有下一步) 读 round_step(round_id, step+1);UPDATE round revealed_step = step+1;UPDATE round_step(step+1) revealed_at = now;COMMIT API publish -1010 {roundId, step:step+1, phase, freeNo, freeTotal, grid, wins, multiplier, stepWin, hasNext, balance} 这是下一屏 has_next = false(这是末步) UPDATE round status=2, settled_at=now(active 自动变 NULL);UPDATE player_game spins+1, total_bet+=bet, total_win+=total_win, max_win, jackpot_hits;COMMIT API publish -1010 {roundId, balance, settled: true} 不带盘面,客户端靠 settled 区分入账确认 释放锁 -1010:hasNext=true → 当新一屏播,播完再 1010;hasNext=false → 更新余额、弹本把 / 免费旋转结算,这时才能再 1011 等不到 -1010:把同一条 1010 再发(旧步会被忽略、当前步会重推;末步 ack 后局已转完也被忽略),或发 1001(round: null + 余额就是确认)。客户端全程不本地加减余额,余额只认 -1011 / -1010 / -1001 带的数

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,超时才由任务结算。

D. 断线 ≥ 3 秒:presence 轮询 → 先关门(cashing_out)→ disconnect → 扣光 wallet → 商家加款 → cashed_out 客户端 商家 大厅 Centrifugo 游戏服 DB / Redis 游戏 CF 连接断(杀进程 / 断网 / 切后台 / 被 kick)。CF ping 超时把连接移出频道,presence 里没他了 定时任务每秒一轮:抢 Redis 锁 lock:presence(多台大厅只有一台跑);逐款游戏执行下面步骤 GET presence slot:{gameId} → {users: 在线 uid 集合} CF 调不通 / 超时 → 本轮这款整体跳过,不写 missing_since、不下分。不能把「问不到」当「不在」 SELECT session WHERE game_id=? AND status=credited AND connected_at IS NOT NULL(跨 ns 一次,uid 全局唯一;人切到别款后 game_id 已改,不在这里)→ expected 集合 missing = expected − online;back = expected ∩ online UPDATE session SET missing_since=now WHERE uid IN missing AND missing_since IS NULL;UPDATE session SET missing_since=NULL WHERE uid IN back 3 秒内重连:客户端用原 JWT 再连 → connect proxy 见 credited → 放行(不调商家)→ 下一轮在 online 里 → missing_since 清空。大局不动 选出 missing_since ≤ now − 3 秒 且本轮仍不在 online 的单子 UPDATE session SET status=cashing_out WHERE id=? AND status=credited 影响 0 行 → 别的任务已处理,退出。影响 1 行 → 关门成功,此后 connect proxy 见 cashing_out 一律拒 retry disconnect {user: uid}(刚好重连上的也被踢,不会有人在下分中途进来 ack) BEGIN;SELECT wallet FOR UPDATE(大厅自己写,不调游戏服。与游戏服 1010 同一行锁:在途 1010 要么先入账一起扣走、要么排后面读到 session=cashing_out 而放弃) amt = wallet.balance;UPDATE wallet SET balance=0;INSERT wallet_log(−101, −amt)(amt=0 不写);INSERT transfer(direction=2, amount=−amt, status=0, session_id); UPDATE session cashout_amount=amt;COMMIT 进行中的 round 不动:已 ack 的小局赢分已在 wallet 里随 amt 下走;未 ack 的小局赢分留在 round_step(paid_at NULL),人回来接着 ack 才入账 amt = 0(钱都冻在未完局里)→ 不调商家,直接 transfer status=1、session cashed_out POST 加款 {merchant_uid, game_id, amount: amt, req_id: transfer.id, ts, sign} 商家按 req_id 幂等,超时 3 秒 200 {merchant_tx_id, balance_after} UPDATE transfer status=1, merchant_tx_id, finished_at;UPDATE session status=cashed_out, cashout_at=now 未知(超时 / 5xx)→ transfer status=3,session 留 cashing_out;按退避 5s→30s→2m→10m 先「查询」再用同一 transfer.id 加款,商家只加一次,超 1h 告警、24h 人工(2.6)。业务拒绝 → status=2 告警人工 同一轮顺手(都先拿 lock:connect):① 未完局超时:round status=1 且 updated_at < now − T;该 uid 最新 session 是 cashed_out → 派彩进 wallet 后拉回 cashing_out 再下分;是 credited 但 game_id 是别款 → 派彩进 wallet 就完,不下分。② pending 超 10 分钟:用 transfer.id 问商家,扣了补事务二 → session pending→cashing_out → 走下分退回;没扣置 failed 未完局超时结算:paid_at IS NULL 的各步逐步 UPDATE wallet += step_win + wallet_log(201/202, paid_by=2) → UPDATE round status=3 → player_game 累加 → UPDATE session SET status=cashing_out WHERE id=? AND status=cashed_out → 再走上面「扣光 wallet → 商家加款 → cashed_out」(新 transfer 挂原 session_id,人不在线不用 disconnect)

E. 下分后再进

对应 0.7、4.2。要点:没有单独接口,就是再连一次走 A 的连接部分;session 已 cashed_out → 新上分;amount=0 只有有未完局才放行;-1001 带 revealed_step,客户端从那一步接着播、接着 1010,赢分打进这次的 wallet。

E. 下分后再进:没有单独接口,就是再连一次 → connect proxy 见 cashed_out → 新上分 → 1001 带当前步 → 接着 1010 客户端 商家 大厅 Centrifugo 游戏服 DB / Redis D 段跑完后客户端被 disconnect;玩家再点进来,或客户端被拒后自动重连(最多 5 次,间隔 1 秒) 原 JWT 还没过期(24h)→ 直接用它连。过期了 → 回商家页面重新 /launch 取地址(A 段前 8 步)再连 connect(token) connect proxy {user, client, claims} 锁 lock:connect;SELECT session 最新一行 → cashed_out 还是 cashing_out(D 段没跑完)→ 拒 retry → 客户端 1 秒后再连 事务一:INSERT session(pending, jti, client) · INSERT transfer(direction=1, status=0) POST 扣款 {merchant_uid, game_id, req_id: transfer.id, ts, sign} 不传金额 按该用户在商家侧的余额全部扣走(刚被 D 段加回去的钱 + 商家侧新充的) 200 {amount: 库存整数, merchant_tx_id, balance_after} 0 也算成功 amount = 0 时:查 round(active=1) amount = 0 且无未完局 → session failed → 拒 no_balance。amount = 0 且有未完局 → 照常 credited 加 0 分,只能 ack 播完,1011 会被余额不足拒局 事务二:transfer status=1, amount · wallet += amount · wallet_log 101 · session credited, amount_in, connected_at · player_game.sessions+1 200 {channels: [user, slot:{gameId}]} 放行 + 服务端订两条频道 publish 1001 publish proxy 1001 读 wallet · round(active=1) · round_step(revealed_step) API publish -1001 {balance: 新上分后的余额, round: {roundId, reqId, step: revealed_step, phase, grid, …, hasNext}} -1001 → 客户端从 revealed_step 那一步继续播(step=0 当 -1011、否则当 -1010),播完发 1010 之后完全同 C 段:每步 1010 把该小局 stepWin 打进这次新上分后的 wallet;全部 ack 完 status=2;hasNext=false 后才能 1011 走 B 段。水位、条带、目标 RTP 都是开局那把锁死的,不重抽

0.1 取游戏地址(商家调大厅,不动钱)

  1. 玩家在商家页面点这款游戏。商家调大厅 POST /launchmerchant_uidgameIdreq_id,可带 nickname / avatar / vip_level / lang。不带金额,这一步商家不扣款。
  2. 大厅 UPSERT player(按 ns + merchant_uid 拿内部 uid),签 JWT(sub=uidgameIdjtiexp 24 小时),把带 token 的 gameUrl 回给商家。不插 session、不动 wallet、不调商家扣款。
  3. 商家把 gameUrl 交给玩家。同一个人重复取,每次给新 token;旧 token 到期前仍能连,谁先连谁算(见 0.2 挤号)。

第一次进、下分后再进、换设备进,都是这一条。没有「第一次」和「再进」两套。取地址失败(商家签名不对、游戏下架、玩家冻结)直接回错,玩家进不了。

0.2 连上即上分(connect proxy 里完成)

钱只在玩家真的连上时才动,由大厅在 connect proxy 里去商家拉。上分方向只有一种:大厅调商家扣款,商家按该用户在商家侧的余额全部扣走并返回金额。商家永远不主动往我们这里推钱。

  1. 玩家打开 gameUrl,页面从 URL 取出 JWT,连 Centrifugo。
  2. Centrifugo connect proxy 打到大厅,带 JWT claims 和这条连接的 client id。大厅验签、拿 uid / gameId;取 Redis 锁 lock:connect:{ns}:{uid}(一人一把,不分款),拿不到 → 拒绝(retry),客户端 1 秒后再连。
  3. 查这人这款最新一行 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。放行。
  4. 放行响应带 channelsslot:{gameId}:user:{uid}slot:{gameId},由服务端替他订。客户端不自己订,不可能漏订游戏频道。
  5. 连上后客户端先在 user 频道 publish 一条 1001(recover),游戏服回 -1001:带当前余额、当前奖池;有未完局再带当前已揭示步接着播。频道不开 history,玩家和游戏服之间没有 HTTP。
  6. 玩家在 user 频道 publish 1011 / 1010。Centrifugo publish proxy 把这条打到任意一台游戏服(无状态,不订频道、不分配、不退订)。游戏服算完用 Centrifugo 服务端 API publish -1011 / -1010 到 user、-1101 到游戏频道。
  7. 大厅不连 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、存大局表

  1. 玩家在 user 频道发 1011(req_id + 下注)。
  2. 先查 session:最新一行不是 credited、或 game_id 不是这款(正在下分 / 已下分 / 切到别款)→ 忽略,不回。再查大局表:同一 uid + gameId + reqId 已有这行:不再扣注、不重抽。这行还在进行中 → 推当前已揭示步(step=0 用 -1011,之后用 -1010);这行已转完 / 超时结算 → 回 -1011 reason: settled 带当前余额,不重推盘面(否则客户端播完再 1010 会被当没有进行中局忽略,卡死)。
  3. 表里有未转完的局、但 reqId 不是这一次:回 -1011 拒局,不扣注,不抽奖。冻着的局必须先播完。
  4. 既没有同 reqId 的行、也没有进行中的局:游戏余额不够或维护,同样拒局。否则才扣注、抽奖。
  5. 读一下这款游戏的水位(不加锁,Redis 或库快照)。水位 = 到目前为止「按目标 RTP 该吐的钱」−「实际吐的钱」,正数欠玩家、负数玩家赢多了。
  6. 按水位选表:先取运营选的风格(温和 / 刺激)那一组,再看水位:欠玩家超过阈值 → 高表(如 98%);玩家赢多超过阈值 → 低表(如 94%);区间内 → 中表(如 96%)。同一组三张表符号、玩法、连消规则、命中率一样,只有大奖权重不同。
  7. 从选中的那张表抽 1 条完整大局:有连消的带齐全部小局,没有连消的只有 step=0。算出总奖额。不筛、不重抽。
  8. 扣注、写流水、写大局表、改奖池在同一个事务里提交(记下用的 tableId 和当时水位;jackpot_pool.amount += bet × contribution − jackpot_win)。中途挂了整体回滚:注没扣、表里没行,玩家用原 reqId 重发就当新局。禁止扣了注却没写表。
  9. 事务外:水位 += 下注 × 目标RTP − 总奖额,一条 INCRBY。不锁、不 CAS。水位只是选表信号,晚一秒、差几块不影响钱。
  10. 事务提交后才推当前这一小局:-1011 step=0。不带后面几消,不带本局总赢分。

同一 uid + gameId 大局表里最多一行未转完。有这行时:同一 reqId 再 1011 就从表取出;换新 reqId 则拒局,只能继续 ack。转完改状态,不删;转完之后同 reqId 再来只回 settled

0.4 正常转(含连消)

  1. 前端收到 -1011 再停轮、播动画。这条 -1011 带这一小局的盘面和 stepWin,不带后面几消,不带本局总赢分。
  2. 播完发 1010 ack(round_id + 当前 step)。ack 只能从大局表取。先看 session:不是 credited(正在下分 / 已下分)→ 忽略,不加分,钱不会打进已关的钱包。没有进行中的大局:忽略,不加分。发来的 step 比当前 revealedStep 小(旧 ack 重发):忽略,不加第二遍分。只有 step == revealedStep 才把这一小局 stepWin 加进余额一次。
  3. 还有下一步(当前盘面 hasNext=true):仍从大局表取下一小局,再推一条 -1010。这是下一屏,继续播、再 ack。
  4. 没有下一步(当前盘面 hasNext=false):这已经是最后一屏,不要再播。1010 后加上这一小局的分,再回一条 -1010,带入账后余额和 settled: true不当新盘面
  5. 转完了,大局 status 改为「转完」,不删。大局、小局永久留着,回放和对账用。这人这款没有「进行中」状态的大局了,才能再发 1011。

客户端不本地加减余额。扣注看 -1011(成功有盘面,拒局有 reason);每一小局入账看这次 1010 之后那条 -1010 的余额。

写入发生在 1011 开奖;ack / 下一步 -1010 / recover 都只读这张表;转完改状态不删,一人一款最多一条进行中。断线不动。

0.5 断线不到 3 秒:当没断过

  1. 玩家这条游戏 CF 断了(杀进程、闪断、弱网)。Centrifugo 把这条连接从频道里移掉,presence 里没他了。
  2. 轮询任务发现库里该在的 uid 不在 presence:单子写 missing_since=now。大局表不动,空闲分先不下。
  3. 3 秒内用原 gameUrl 上的 JWT 再连上,connect proxy 又替他订回游戏频道,presence 里又有他:下一轮把 missing_since 清空。
  4. 连上先发 1001,-1001 从表里取当前步继续播。不重抽、不重新上分、不调商家。

判定只看「此刻 presence 里有没有这个 uid」。后进挤先进时新连接已经在,旧连接怎么断都不影响,不会把还在玩的人下分。

0.6 断开超过 3 秒:下空闲分

  1. missing_since 距今 ≥ 3 秒,且本轮 presence 里仍没有这个 uid。
  2. 先关门UPDATE 单子 SET status='cashing_out' WHERE id=? AND status='credited'。影响 0 行 → 别人已在处理,退出。此后 connect proxy 见 cashing_out 一律拒绝。
  3. CF disconnect 该 uid。人若刚好在这几十毫秒里重连上了,也被踢掉;不会有人在下分中途挤进来 ack。
  4. 再搬钱:大厅自己在一个事务里 SELECT wallet FOR UPDATE、扣光空闲游戏分(还能拿来下注的余额)、写 wallet_log、插 transfer。不经过游戏服。和 1010 入账靠同一行的行锁互斥:在途的那条 1010 要么先入账再被一起扣走,要么排在后面读到 session 已是 cashing_out 直接忽略。然后回调商家加回真钱。
  5. 正在播的连消不停、不结算、不重抽。大局表这一行留着。已经 ack 过的小局赢分已经在游戏余额里,会随空闲分下走;还没 ack 的小局赢分不算进这次下分。
  6. 空闲分为 0(钱都冻在未完连消里)也走同一串,只是不调商家加 0。
  7. 单子改 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 连接与进厅

  1. 商家调大厅 POST /launchmerchant_uidgameIdreq_id)。大厅 UPSERT player、签 JWT(sub=uid、gameId、jti、exp 24h),把带 token 的 gameUrl 回给商家。不动钱。
  2. 玩家打开 gameUrl,页面从 URL 取出 JWT 连 Centrifugo。不另打接口拿 token。
  3. Centrifugo connect proxy 打到大厅:验 JWT,按 session 状态分路——credited 挤号放行;cashing_out 拒 retry;pending 用原 transfer.id 补问商家;cashed_out / failed / 无 → 大厅调商家按余额全额扣款、加游戏分、插 session(credited) → 放行。放行响应下发 channels(user + 游戏频道),服务端替他订。
  4. 连上先 publish 1001,收 -1001。之后点旋转:publish 1011。同一用户后进挤先进,不分款:换设备、刷新、从 A 游戏切到 B 游戏都是同一个动作(大厅 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;不能往游戏频道发;不能发负数。两条频道同一个 namespace,Centrifugo 的 publish 权限是 namespace 级、分不开,所以「不能往游戏频道发」由 publish proxy 兜底:游戏服校 channel == slot:{gameId}:user:{proxy.user},对不上一律拒,不区分是哪条频道。订阅全部由 connect proxy 服务端下发,客户端不自己 subscribe。大厅不连 CF,只调 HTTP API(presence / publish / disconnect)。心跳用 Centrifugo ping/pong,客户端不回 pong 即判死、从 presence 移除。

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。

codetype频道何时发data 要点
1001 recover slot:{gameId}:user:{uid} 每次连上 CF 后固定先发一条;等不到 -1011 / -1010 时也发 空。游戏服只信 proxy 里的 uid 和频道
-1001 recover slot:{gameId}:user:{uid} 回 1001。只读,不抽、不改表、不动水位 必带 balancejackpot(当前池)、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 revealedStepsettled(同 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.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 proxyuser 频道上的 1011 / 1010 打到任意一台游戏服。只放行正数;负数拒
大厅 → Centrifugopresence 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 频道。

  1. publish proxy 请求里带 CF 已认证的 user(uid)和 channel。游戏服只信这两个,不信 payload 里写的 uid。频道里的 gameId、uid 和 JWT 对不上就拒。
  2. 游戏服收到任何一条都先读这人最新 session:不是 credited、或 session.game_id 不是这条频道的 gameId → 忽略(正在下分 / 已下分 / 人已切到别款,钱包对这款关着)。1001:读余额、奖池、进行中大局 → API publish -1001。1011:先按 uid + gameId + reqId 查大局表 → 进行中就重推、已转完回 settled;没有就扣注、抽奖、写表、改奖池(一个事务)→ API publish -1011。奖池有变 → 节流 publish -1101。1010:只读表 → 入账 → API publish -1010。
  3. proxy 放行后原 1011 / 1010 会广播回频道,客户端丢掉自己的回声。负数 publish 一律拒。
  4. 同一 uid 串行:游戏服按 uid + gameId 拿 Redis 锁,只用来把同一玩家的 1001 / 1011 / 1010 排队;钱的互斥不靠它,靠 wallet 那一行的行锁(SELECT ... FOR UPDATE / 原子 UPDATE balance = balance ± ?)+ round_step.paid_at 幂等。大厅下分不拿这把 Redis 锁,也能和 1010 互斥。
  5. 游戏服挂一台无感:CF 打到下一台。不需要分配、通知订阅、退订、接管。
做法结论
CF publish proxy → 任意游戏服 → API publish 回频道 采用。游戏服无状态。多一跳机房内 HTTP,毫秒级
游戏服连 CF 订每个玩家的 user 频道 不用。要分配玩家到某台、上分通知订、下分通知退订、保证只有一台、挂了要接管。整块订阅管理都省掉
玩家直打游戏服 HTTP 没有。recover 也走 1001,游戏服不对玩家开端口

1.5 一次旋转(含连消)

一次 1011 开的是一条大局(一个 round_id)。有连消时,大局里面按步拆成多条小局;没有连消时,大局里只有一条小局(step=0)。

是什么客户端怎么看到
大局 点一次旋转。整串连消已经抽完,总奖额 = 各小局 stepWin 之和 看不见整串、看不见总奖额、看不见用的哪张表
小局 连消的一步:step=0 停轮,step=1 第一消,以此类推 step=0 是 -1011;之后每步是 -1010。播完发 1010,这一小局的 stepWin 入账;有下一步再收一条 -1010
  1. 客户端在 user 频道发 1011,带 req_id + 下注额。转轮可以先空转。
  2. 先看 session 是不是 credited 且 game_id 等于这款,不是就忽略。再查大局表(uid + gameId + reqId)。有这行且进行中:不扣注、不重抽,取出当前已揭示步推回去(step=0 用 -1011,之后用 -1010)。有这行但已转完:回 -1011 settled 带余额,客户端据此换新 reqId。网络卡住又用同一 reqId 发 1011,走这条。
  3. 有未转完的局但 reqId 不同、余额不足、维护:publish -1011(拒局),不扣注。冻着的局必须先播完。
  4. 既无同 reqId 的行、也无进行中的局,才扣游戏余额,按第 3 章看水位选表、抽 1 条,整条大局写入大局表(含全部小局),奖池注资 / 爆池扣减同一事务。publish -1011 step=0(带扣后余额)。不带整串、不带本局总赢分。
  5. 客户端收到 step=0 再停轮。播完发 1010 ack(round_id + step=0)。游戏服只从大局表取。没这行、或 step 小于当前 revealedStep:忽略,不加分。只有对得上当前步才入账一次。
  6. 有下一步(这条盘面 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。

stepphase是什么客户端
0主转停轮,3 个 scatter播停轮,弹「获得 10 次免费」,发 1010
1连消主转的一次连消自动播消除补落,播完自动发 1010,不等玩家
2免费 1/10第一次免费旋转停轮等玩家按键(或自动),播停轮,发 1010
3免费连消免费旋转里的连消自动播消除,自动发 1010
4免费 2/10
中途再触发:free_total 从 10 变 13,后面多 3 步弹「追加 3 次」
末步免费 13/13hasNext=false播完发 1010,收带余额的 -1010,弹免费旋转总结算

为什么不另开局:另开局要记「这人还有 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 硬约定

1.7 待后续完善

2. 上下分

上分只有一条:商家调 /launch 取地址(不动钱)→ 玩家连 CF → connect proxy 里大厅调商家按该用户余额全部扣走、加游戏分、插 session → 放行。第一次和下分后再进完全一样。下分:大厅定时任务轮询 presence,连续 3 秒不在才下空闲分。大厅无状态,状态全在单子表。游戏服不报断开、不调商家。

2.0 流程图

左半是上分(全部在 connect proxy 里完成,四路分支对应 2.1.1),右半是下分(每秒轮询任务,对应 2.4)。黄菱形是判断,红框是拒绝 / 终止,绿框是正常终点。

F. 上下分:左 = 上分(connect proxy 里完成),右 = 下分(presence 轮询任务) 蓝框动作 · 黄菱形判断 · 绿框正常终点 · 红框拒绝/终止。所有钱的动作都是大厅调商家,商家不主动推 不通过 通过 没拿到 拿到 credited cashing_out pending cashed_out / failed / 无 放行 用原 transfer.id 再调 未知 业务拒绝 成功 {amount, merchant_tx_id} 否(0 但有未完局也往下) 0 行 1 行 成功 未知 / 业务拒绝 退避到点:查询 → 同一 transfer.id 再调 商家 POST /launch {merchant_uid, gameId, req_id} UPSERT player · 签 JWT(sub=uid, gameId, jti, exp 24h) · 回 gameUrl 。不插 session、不动钱 玩家 connect(JWT) → Centrifugo connect proxy → 大厅 {user, client, claims} 验签 / exp / player 冻结? 拒 denied:回商家重新取地址 SETNX lock:connect:{ns}:{uid} 拿到 ?(一人一把不分款) 拒 retry:客户端 1 秒后重连 这人最新一行 session 状态?( 不带 game_id) credited:挤号(同款重连 / 换设备 / 换款都是这条)。-1002 → disconnect(uid, whitelist 新 client) → 覆盖 game_id/jti/client、 清 missing_since。不动钱 cashing_out:拒 retry(下分几百毫秒 完,重连就成新上分) pending:用这行的 transfer.id 再调商 家扣款(幂等返回同一结果) cashed_out / failed / 没有 → 事务一 :INSERT session(pending, jti, client) · INSERT transfer(direction=1, status=0) 调商家扣款 {merchant_uid, game_id, req_id=transfer.id, ts, sign}。不传 金额,超时 3 秒。商家熔断中不调、直接 拒 retry 商家结果? 未知(超时 / 5xx):transfer=3、 session 留 pending → 拒 retry。不冲 正,下次连上按 pending 再问;10 分钟 后对账任务退避查询 业务拒绝:transfer=2、session failed → 拒 denied。不重试 amount = 0 且没有未完局? session failed → 拒 no_balance:提 示回商家充值 事务二:transfer=1, amount, merchant_tx_id · wallet += amount · wallet_log(101) · session credited, amount_in, connected_at · player_game.sessions+1 放行 {channels: [slot:{gameId}:user:{uid}, slot:{gameId}]},释放锁 → 客户端 connected → 发 1001 大厅定时任务,每秒一轮(Redis 锁,多 台只跑一台) GET presence slot:{gameId} → online 集合 CF 调得通? 本轮整款跳过:不写 missing、不下分。 不能把「问不到」当「不在」 expected = session credited 且 connected_at 非空。不在 online → missing_since=now(已有不动);在 → 清空 missing_since ≥ 3 秒且本轮仍 不在? 等下一轮。3 秒内重连 → connect proxy 见 credited 放行,不调商家 关门:UPDATE session SET status=cashing_out WHERE id=? AND status=credited 影响 1 行? 0 行:别人先到了,退出 CF disconnect(uid)。此后 connect proxy 见 cashing_out 一律拒 retry 大厅自己写,不调游戏服。事务:SELECT wallet FOR UPDATE(与 1010 同一行锁 )→ amt=balance → wallet=0 · wallet_log(−101) · INSERT transfer(direction=2, −amt) · session.cashout_amount amt > 0? amt=0(钱都冻在未完局):transfer=1 → session cashed_out,不调商家 调商家加款 {merchant_uid, game_id, amount=amt, req_id=transfer.id, ts, sign},超时 3 秒 商家结果? 成功:transfer=1, merchant_tx_id → session cashed_out, cashout_at 未知:transfer=3,session 留 cashing_out,退避 5s→30s→2m→10m 先 查再同一 transfer.id 重试,超 1h 告 警。业务拒绝:transfer=2 告警人工 未完局不动:已 ack 赢分随 amt 下走; 未 ack 留在 round_step,人回来接着 ack 才入账
怎么知道大厅做什么
商家 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(商家调)

  1. 商家带 merchant_uidgameIdreq_id,可带 nickname / avatar / vip_level / lang(带了就覆盖 player)。签名用商家密钥。不带金额。
  2. 大厅校商家签名、game 上架、玩家未冻结;UPSERT player(ns + merchant_uid → uid)。
  3. 签 JWT:sub=uidgameIdjti(随机)、exp 24 小时。拼进 gameUrl 返回,同时回 orientation 等展示信息。
  4. 失败直接回错,无地址。这一步没有任何钱的动作,重复调多少次都无副作用。

同一个人多次取地址:每次新 jti,旧 token 到期前也能连。谁先连 connect proxy 谁把 jti 写进 session,后来的挤先来的。

2.1.1 连上即上分:connect proxy(POST /internal/cf/connect

钱只在这里动。Centrifugo 把 JWT claims 和这条连接的 client id 打过来,大厅在超时(5 秒)内完成下面的事,返回放行 + channels,或拒绝 + reason

  1. 验签、验 exp;拿 uid / gameId;读 player.status,冻结 → 拒 denied
  2. 取 Redis 锁 lock:connect:{ns}:{uid}(TTL 6 秒,一人一把不分款)。拿不到 → 拒 retry。同一个人两条连接同时进来(不管进的是不是同一款),只有一条会去调商家。下面不论放行还是拒绝,返回前都释放锁;商家超时那条也释放,靠 pending 状态接力,不靠锁。
  3. 查这人最新一行 session(不带 game_id,一人只有一张开着的单):
    session做什么动钱
    credited挤号:API publish -1002 到旧 game_id 的 user 频道;CF disconnect(user=uid,whitelist=这条新 client);UPDATE session SET game_id=新, jti=新, client=新, missing_since=NULL, connected_at=now。放行。刷新、换设备、从 A 款切到 B 款都是这一行,钱在 wallet 里不动;A 的未完局留在 round 表,回 A 时 1001 带出来接着放,或按 4.3 超时结算
    cashing_outretry。下分几百毫秒就完,客户端 1 秒后重连落到下一行
    pending上次调商家没拿到结果。用这行的 transfer.id 再调商家扣款(商家幂等)。拿到金额 → 走第 5 步补 credited → 放行;又超时 → 拒 retry;商家明确失败 → session failed、transfer 失败 → 拒 denied补问
    cashed_out / failed / 没有新上分,第 4、5 步
  4. 事务一INSERT session(status=pending, game_id, 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
  5. 事务二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,但不写 wallet_log(金额为 0 的流水一律不记,transfer 上有痕迹就够;wallet_log 要求 amount 与 subject_code 同号,0 没有符号)。
  6. 放行:响应 channels: [slot:{gameId}:user:{uid}, slot:{gameId}]。释放锁。

拒绝的 reason 只有三个:retry(客户端 1 秒后自动重连,最多 5 次;5 次仍被拒就按 denied 处理,提示「网络异常」回商家重新取地址)、no_balance(提示回商家充值)、denied(回商家重新取地址)。

2.1.2 失败怎么收(已定)

情况处理
取地址失败回错,无地址。没有钱的动作
拿了地址不连 / JWT 过期什么都没发生,不用退。过期后连 → denied,回商家再取
商家扣款业务拒绝(用户不存在 / 冻结 / 签名不对,见 2.6)session failed、transfer status=2。拒 denied。没有游戏分,没有会话。不重试
商家扣款未知(超时 / 5xx / 网络错 / 回包验签不过)session 留 pending、transfer status=3。拒 retry。下次连上用同一 transfer.id 再调,商家幂等只扣一次。不冲正。商家熔断中直接拒 retry,不等 3 秒
商家已扣成功、我们事务二失败同上:session 仍 pending,下次连上再调商家,商家返回已扣的同一结果,我们补做事务二。钱不丢
商家返回 0、无未完局session failed,拒 no_balance
商家返回 0、有未完局credited、加 0 分、放行。只能 ack 播完,1011 会被余额不足拒局
已 credited 再连(换设备 / 3 秒内重连)不调商家、不叠加游戏分。挤旧连接
cashing_out 时连retry,等下分完再连就是新上分
pending 超过 10 分钟对账任务用 transfer.id 调商家「查询」,按 2.6 的退避反复问(不是一次):查到已扣 → 补事务二把钱进 wallet,条件更新 pending → cashing_out,走 2.4 的下分把钱退回商家,最后 cashed_out(人不在线,不需要 disconnect);查到未扣 → failed;查不通 → 下个退避点再问。人再连时若还是 pending 也走 2.1.1 第 3 步;对账任务和 connect proxy 靠同一把 lock:connect 互斥

2.2 为什么在 connect proxy 里扣

2.3 接口

接口要点
商家 → 大厅POST /launch取地址。merchant_uid、gameId、req_id、可选展示字段。返回 gameUrl(JWT 24h)、orientation。不动钱
Centrifugo → 大厅connect proxy验 JWT;按 session 分路;新上分时调商家扣款、加游戏分、插 session;放行下发服务端订阅;拒绝带 reason
Centrifugo → 游戏服publish proxy1001 / 1011 / 1010 打到任意一台游戏服
大厅 → Centrifugopresence定时任务每秒每款一次,拿在线 uid 集合
大厅 → Centrifugopublish / 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, ts, sign},返回 {status: done | not_found | processing, amount, merchant_tx_id}。上分 pending 对账、下分 status=3 重试前都先查它,查到 done 直接补账不再重调

商家要实现的只有三个回调:扣款、加款、查询,都按 req_id 幂等。我们对商家开放的只有 /launch。表里没有「大厅 → 游戏服」「游戏服 → 大厅」这两行:两者之间没有接口,只共享 DB / Redis。

2.4 下分(已定)

OSS 没有 disconnect HTTP 回调,也不靠 join/leave。大厅不连 CF,用一个带 Redis 锁的定时任务每秒轮询 Centrifugo presence API。「掉线」的定义:库里说他该在,presence 连续 3 秒说他不在。

对比的两边:expected = 单子表里 status=credited、game_id=这款且 connected_at 非空的 uid(connect proxy 放行时写);online = presence slot:{gameId} 返回的所有 user(JWT sub 填 uid 才对得上)。一款一次调用拿齐全部在线人。人切到 B 款时 session.game_id 已经改成 B,所以 A 的轮询不会再找他。

  1. 每轮对每款游戏:uid ∈ expected∉ onlinemissing_since 为空就写 now,不为空不动。uid ∈ onlinemissing_since 清空。
  2. missing_since 距今 ≥ 3 秒且本轮仍不在,按固定顺序下分:
    1. UPDATE 单子 SET status='cashing_out' WHERE id=? AND status='credited'。0 行 → 退出。此后 connect proxy 拒绝这单。
    2. CF disconnect 该 uid。已连着的踢掉。
    3. 大厅自己一个事务:SELECT wallet FOR UPDATE → 取 balance 为 amt → UPDATE wallet SET balance=0INSERT wallet_log(−101, −amt)(amt=0 不写)→ INSERT transfer(direction=2, −amt, status=0)session.cashout_amount=amt。与游戏服 1010 靠同一行行锁互斥:在途 1010 要么先入账再一起扣走,要么排后面读到 session 已是 cashing_out 而忽略。不调游戏服。
    4. 商家加款(幂等,带 req_id)。
    5. 单子 status='cashed_out'。
    大局表这一行不动。第 1 步失败或第 2 步没做完,不准进第 3 步。
  3. 同一轮顺手处理:未完局超时(4.3)、pending 超 10 分钟对账(2.1.2)。这两条最后都要「再下一次分」,走的就是上面第 3~5 步,只是 session 由任务先条件更新到 cashing_out(从 cashed_out 或 pending),transfer 挂原 session_id。没有「发地址 30 秒不连」这条:连上之前钱没动。
  4. presence 调不通(CF 挂、超时):本轮整款跳过,不写 missing、不下分。不能把「问不到」当「不在」,否则全员下分。
  5. 量级presence 返回该频道每个 client 的完整信息,一款几千人在线就是每秒几 MB JSON,是对 CF 的重操作。上线前按峰值在线数压一次;超过阈值(如 2000 人 / 款)把周期放到 2 秒,或先调 presence_stats 看人数没变就跳过本轮。3 秒判定不变。
  6. 卡在 cashing_out 的单子(商家加款未知、或任务中途挂了):每轮同时扫 status=cashing_out AND next_retry_at ≤ now,找该 session 最新一条 direction=2 且 status∈{0,3} 的 transfer:先调商家「查询」,done → 直接置成功;否则用同一 id 再调加款。退避、上限、告警见 2.6。没有 transfer(挂在 disconnect 和扣光之间)就从第 3 步接着做。钱只会动一次:wallet 已经是 0 就不再插流水。
  7. 任务用 Redis 锁保证同一时刻只有一台在跑;每台大厅都起这个任务,谁抢到谁跑。哪台挂了,下一秒别的接上。
  8. 下分之后再进:客户端拿原 JWT 再连(没过期),或回商家重新 /launch 取地址再连。connect proxy 见 session 已 cashed_out → 新上分:调商家全额扣、加游戏分、插新 session(credited) → 放行(2.1.1)。余额为 0:有未完连消则放行加 0 分(只能 ack 播完),没有未完局拒 no_balance。
  9. 连上 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 里,轮询看到他在,不下分。

2.6 商家接口挂了怎么办(已定)

商家三个回调(扣款 / 加款 / 查询)都可能超时、报 5xx、整体不可用。原则:钱的事只能靠幂等 + 重试收敛,不能靠人工兜;商家整体挂掉时快速失败,不把 connect proxy 的 5 秒预算白白等掉。

2.6.1 结果分三类

类别怎么判transfer.status之后
成功HTTP 200、验签通过、code=0,带 amount / merchant_tx_id1走正常流程
业务拒绝HTTP 200、验签通过、商家明确给业务错误码:用户不存在 / 用户冻结 / 我方签名不对 / 参数错2不重试。上分 → session failed、拒 denied;下分 → 告警 + 人工(这是唯一允许人工的口,且必须是商家明确说不收)
未知超时、连不上、HTTP 非 200(含 5xx / 502 / 504)、回包验签不过、回包解析失败、商家返回「处理中」3一律按未知重试,不许当失败。商家自己 500 了也可能已经扣 / 加成功,只有幂等重试或查询能知道

「商家挂了」在这套口径里只是大量「未知」;没有单独的分支。

2.6.2 重试:退避、先查再调、上限

上分扣款(pending)下分加款(cashing_out)
谁重试玩家重连时 connect proxy 用原 transfer.id 再调(最多 5 次、间隔 1 秒,客户端驱动);玩家不回来由对账任务接手大厅定时任务
节奏对账任务:pending 满 10 分钟起,按 1 → 5 → 30 分钟 → 之后每小时第 1 次立刻,之后 5 秒 → 30 秒 → 2 分钟 → 10 分钟 → 之后每 10 分钟。transfer.retry_count / next_retry_at 记在流水上
每次先做什么先调「查询」:done → 补事务二;not_found → failed;查不通 → 等下个点先调「查询」:done → 直接 status=1、session cashed_out,不再调加款;not_found / processing / 查不通 → 再调加款(同一 req_id)
上限24 小时仍查不到结果 → 告警、人工。session 留 pending,人连上还是先补问24 小时仍未成 → 告警、人工。session 一直 cashing_out:玩家这段时间连上都被拒 retry,这是对的——商家挂着本来也上不了分
告警任一笔重试超过 1 小时;任一笔落到 status=2;同一商家 1 分钟内未知 ≥ 20 笔。三种都报

重试永远用同一个 transfer.id,不插新流水;商家侧按 req_id 幂等是接入的硬要求,「查询」接口是第二道保险,两个都要有。退避序列只是默认值,产品可调;退避是给商家喘气,不是给我们省事——上限内不允许放弃。

2.6.3 熔断:商家整体不可用

客户端在熔断期间看到的是连续 retry,5 次后按 denied 提示「暂时无法进入,请稍后再试」回商家;商家侧自己知道自己挂了,这个提示够用。

3. RTP:水位选表与返水

不在运行时改权重、不筛结果。数学组给一组完整的赔付表:按「风格 × RTP 档」排成表格,风格(温和 / 刺激)运营在后台选,RTP 档(低 / 中 / 高)每把 1011 看水位自动切。选定一张,抽 1 条就是结果。水位是负反馈信号:吐多了切低表、吐少了切高表,长期 RTP 精确收敛到运营填的目标。返水仍在转轮外补差额。

3.0 流程图

左列是每把 1011 的路径(幂等 → 拒局 → 读水位 → 选表 → 抽 → 事务 → 事务外记水位,对应 3.3 / 3.4);右上是运营后台改目标 / 风格怎么生效(3.8);右下是返水在转轮外怎么算、怎么发(3.6)。

G. RTP:每把 1011 读水位选表、抽 1 条、事务外记水位;后台改目标下一把生效;返水在转轮外 水位 = Σ下注×目标RTP − Σ实际派彩,按 gameId 一份,正数欠玩家。它只决定用哪张表,不否决结果、不进事务 > +阈值 区间内 < −阈值 超区间 可保存 1011 读取 游戏服收到 1011 {reqId, bet} round(uid, gameId, reqId) 已 有这行? status=1:重推 revealed_step( step=0 用 -1011,否则 -1010);已转 完:-1011 settled 带余额,不重推盘面 。不扣注、不抽、不动水位 有进行中局(reqId 不同)/ 余 额 < bet / 维护 / bet 不合法 -1011 拒局 {reqId, reason}。不抽、不 动水位 读 game_config:目标 RTP、风格、阈值 、strips_version(进程缓存 1 秒); GET water:{ns}:{gameId}(快照,不锁 水位 vs ±阈值(例 ±500 注)? 水位 > +阈值(吐少了)→ 该风格的高表 (例 98%) 水位 < −阈值(吐多了)→ 该风格的低表 (例 94%) 区间内 → 中表(例 96%) table_id = 风格.档。锁死本局 target_rtp / table_id / strips_version,中途改配置不影响这把 从该表抽 1 条完整大局:主转 → 连消 → 免费旋转 → 再触发 → 爆池判定。 total_win = Σ stepWin。不筛、不重抽 、不看发得出去 事务:wallet −= bet · wallet_log(−201) · INSERT round(table_id, target_rtp, water_at, total_win) · round_step × N · jackpot_pool 注资/爆池; COMMIT 提交成功? 整体回滚:注没扣、表没行、水位没动 → -1011 retry,客户端原 reqId 重发 事务外:INCRBY water += bet × target_rtp/10000 − total_win。不锁 、不 CAS、晚一秒无所谓 审计:定时把 Redis 水位快照落 water_snapshot;报表 SUM(total_win)/SUM(bet) 按 game_id / table_id;每局 table_id + water_at + total_win 可对表复核 奖池已在事务里改完;每款每秒最多 1 条 -1101,爆池立刻 → publish -1011 step=0 之后 1010 只从 round_step 取、逐步入 账。recover / 断线 / 超时结算都不再动 水位 运营后台:填目标 RTP(0.1 步进)/ 选 风格(温和 / 刺激) 目标在哪个区间? 低于最低表,或高于最高表 + 返水上限 → 拒绝保存,提示可填区间 [最低表, 最高表] 内:水位选表自动收敛 到目标,返水 0。高于最高表:转轮按最高 表跑满,差额 = 返水 UPDATE game_config(target_rtp, style, updated_by)。下一把 1011 生效 ;不清水位;进行中的局锁旧值 同页展示:当前水位、三张表使用占比、实 测 RTP、已发返水、合计。运营不选表、不 配权重 返水(转轮外):下分时 / 商户日结 有效流水 = Σ round.bet(按 session / uid / 日)× 返水率(VIP 分档)。不按 单局输赢加减 商家发到用户真钱账户。不进 wallet、不 进 -1011/-1010、不改水位。wallet_log 301 仅预留 对外总回报 = 游戏内 RTP + 返水率。两 层一次配齐,防叠成超发

3.1 三层各管什么

赔付表水位返水
问的是 这把从哪张表抽(每张表 RTP 固定、完整合法) 到现在吐多了还是吐少了,选哪张表 玩家有效回报还差那一截
管哪一步 1011 抽 1 条完整大局 1011 抽之前读一下、抽之后记一下 下分或商户日结,按流水给
不干什么 不按人改权重;不在运行时改任何一张表 不否决结果、不筛、不重抽;不按人建库存;不进事务 不塞进盘面、不进 -1011/-1010 赢分

对外可说的总回报 ≈ 游戏内 RTP + 返水率(返水按流水时)。两层数字要一次配齐,不要游戏里已经留足 hold、厅里再高额返,叠成超发;也不要两边都克,玩家实拿对不上宣传。

3.2 已定口径

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 里怎么走

  1. 先校 session 是 credited 且 game_id 是这款,不是就忽略。再按 uid + gameId + reqId 查大局表。有这行:不扣注、不抽、不动水位;进行中就取出当前已揭示步 publish(step=0 用 -1011,之后用 -1010),已转完就回 -1011 settled 带余额。
  2. 没有这行。余额不足、已有进行中局(reqId 不同)、维护:publish -1011(拒局),不抽、不动水位。
  3. 读后台当前风格和目标 RTP、读水位(快照,不锁)。风格定组、水位定档,得到 tableId。锁死本局目标 RTP 和 tableId。
  4. 从选中的表抽 1 条完整大局:主转、连消、触发的免费旋转及其连消、再触发,每个小局都算完,总奖额 = 各小局之和(含是否爆池)。不筛。
  5. 开事务:扣注(wallet)、写扣注流水(wallet_log)、把开奖大局(UNIQUE(ns, uid, gameId, reqId),含 tableId、开局水位)和全部小局写入大局表、奖池 jackpot_pool.amount += bet × contribution − jackpot_win提交。失败整体回滚,注不扣、池不动。
  6. 事务外:水位 += 下注 × 目标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、用的哪张表、开局时水位、各小局奖额之和。审计一句话说清
全部小局每步盘面、stepWinhasNext,一步一行永久存(5.2 小局表)。落库时已经齐,ack 不再算
revealedStep最后一次已经推给客户端的小局。断线回来只重推这一步,不把后面几消带出去

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 可见奖池和水位

3.6 返水(转轮外)

返水用来补「有效 RTP」,不要做成第二套抽奖。

3.7 怎么验

3.8 后台怎么设(已定)

运营两个控件,随时改、下一把生效

运营不改符号、不配权重表、不直接选某一张表。RTP 档由水位自动切,运营看不到也不用管。

运营填的系统怎么贴
在最低表和最高表 RTP 之间(例 94~98) 水位选表自动收敛到目标;返水 0
高于最高表 RTP,且不超过「最高表 + 返水上限」 转轮按最高表跑满;差额用返水补。合计 = 运营填的数
低于最低表 RTP,或高于「最高表 + 返水上限」 拒绝保存,提示当前可填区间

3.9 待配数字

口径已定,下面这些要产品/数学组给数再填:几种风格、每种风格各档的 RTP 与最高倍数、切表阈值(多少倍平均注)、每种风格的可玩性指标(3.10)、返水上限、未完大局超时、模拟次数与漂移阈值、1011 耗时上限。可填区间写在后台输入框旁。

3.10 可玩性指标(数学组交付清单)

RTP 只说长期吐回多少,可玩性说这钱怎么吐。同样 96%,可以是每把中一点的温和机,也可以是十把不中一把翻五倍的刺激机。这些全在表的形状里,和 RTP 独立。每张表交表时下面这些数一起交。

指标是什么温和版刺激版
命中率多少把有赢(含赢小于注)高(30%+)低(20%~25%)
波动率赢的大小分布多散低~中
连消触发率 / 平均长度多少把进连消、平均消几步触发多、步数短触发少、步数长
特色玩法频率免费旋转等多少把进一次
大奖频率多少把出一次 ≥50 倍、≥200 倍密(靠条带上大奖符号多,不靠改倍数)
小赢占比赢小于注的比例
本金曲线100 注平均撑多少把

具体数字由数学组和产品定,表里只是方向。

3.11 条带怎么生成(cmd/stripgen

一个 Go 命令行工具,和游戏服同一个仓,直接 import 游戏服的开奖函数(同一份 eval(grid, paytable) → wins)。这一条是硬要求:模拟器和线上不是同一份开奖代码,跑出来的 RTP 就不是线上的 RTP,后面水位选表全部失真。输入目标 JSON,输出 5.3.2 那份条带 JSON + 报告。

3.11.1 输入

来源说明
符号表、赔付倍数、盘面、线 / ways、连消倍数、免费旋转规则game.paytable6 张表共用,工具只读不改
每张表的目标指标3.10 清单 + 产品给的数RTP、命中率、连消触发率、免费触发率、最高倍数上限、波动率(赢分标准差 / 注)。calm.lowhot.high 就差在这组数字
条带长度范围、硬约束工具参数每轮 30~60 格;同轮相邻不重复高分符号;scatter 间隔 ≥ 3 格;wild 不上第 1 轮(可选)
模拟把数、容差、随机种子工具参数迭代中 100 万把,收敛后 1000 万把复核;RTP 容差 ±0.1%,其余指标 ±5% 相对误差

3.11.2 算法

  1. 初始条带:每轮按经验给数量——低分符号(J / Q / K / A)每轮 5~8 格,中分 3~4 格,高分 1~2 格,wild 0~1 格,scatter 每轮 1 格(第 2/3/4 轮可多 1 格)。数量就是概率:轮子 i 上符号 s 落在某一格的概率 = count_i(s) / len_i。随机打乱一次得到初始排列。
  2. 算指标:只有线赔、无连消 / 免费的玩法可以精确算(每条线每个符号的赢概率 = 各轮频率乘积,含 wild 替代;毫秒级)。有连消、免费、ways 的一律蒙特卡洛:调游戏服开奖代码跑 N 把,统计 Σwin / Σbet、命中率、平均连消步数、免费触发率、最大倍数、标准差、按符号拆的 RTP 贡献。Go 写的 5×3 连消游戏 100 万把几秒到几十秒。
  3. 迭代(爬山法):
    loop:
      sim = run(strips, 1_000_000)
      err = w1·|sim.rtp − target.rtp| + w2·|sim.hitRate − target.hitRate|
          + w3·|sim.freeRate − target.freeRate| + w4·|sim.cascadeRate − target.cascadeRate| + …
      if err < 容差: break
      随机挑一个动作:某轮某符号 +1 / −1 格、交换同轮两格、加 / 减一格 scatter
      重跑;err 变小保留,变大退回;连续 200 次不降就随机重启
    经验规则让收敛快得多:RTP 高了 → 第 3~5 轮减 wild / 高分符号或加低分符号;命中率低了 → 前两轮加低分符号;免费触发多了 → 减 scatter;最高倍数超了 → 高分符号在后轮减到 1 格。一张表通常几百到几千次迭代收敛。
  4. 排列也要调:线赔游戏每条线一轮只取一格,RTP 只和数量有关;但 3 行窗口的联合分布和排列有关——同符号相邻会出「叠符号」满屏、scatter 相邻会让一轮同时露两个 scatter 浪费掉。ways 游戏排列直接影响 RTP。所以「交换同轮两格」是迭代动作之一,硬约束在每次动作后检查,不满足直接丢弃。
  5. 六张表分两步:先按风格出一张 mid(calm:低分符号多、连消倍数前两档吃满;hot:低分少、大符号成簇、免费触发略高),再从 mid 派生 low / high——只允许改 wild 和高分符号在后轮的格数,其余格不动,保证 3.10 说的「同一风格内三张档的命中率、连消触发率、免费频率只差极小误差」。派生出来的表再跑一遍迭代,但动作集只开这几个符号。

3.11.3 输出与验收

没有数学师也能用这个工具跑出可用的表;工具解决的是「数字对不对」,「好不好玩」的目标数字仍靠 3.10 的范本或外包那份来定,这就是 7. 里说先买一份范本的原因。改赔付倍数(game.paytable)= 6 张表全部重跑。

3.12 RTP 同事干活流程

负责 RTP 的人只管三样东西:开奖库(一份代码,游戏服和模拟器共用)、模拟器cmd/simulate:给一张表算指标)、条带生成cmd/stripgen:反过来按目标找表)。不碰大厅、上下分、Centrifugo。按下面顺序做,每步有明确产出和验收,前一步没过不进下一步。

做什么产出验收
1 定死赔付表。和产品一起把 5.3.1 那份 game.paytable JSON 填完:符号、倍数、线 / ways、连消倍数、免费规则、奖池注资比例与触发。 paytable.json v1 产品签字;之后改它 = 6 张表全部重跑,所以先定死
2 写开奖库 internal/slot/engine:纯函数、无 DB、无随机源依赖注入。Draw(strips, rng) → gridEval(grid, paytable) → winsCascade(grid, wins, cascadeStrips, rng) → grid'FreeSpin(...)RunRound(strips, paytable, rng) → []step(一把从头跑到底,含连消和免费,就是 1011 存进 round_step 的那些步)。 Go 包 + 单测 用手工构造的盘面做单测:每种赔付、wild 替代、多线同中、连消补落、scatter 触发都有用例;RunRound 给定种子结果可复现。游戏服 1011 直接调这个包,不得另写一份
3 写模拟器 cmd/simulate:输入一张表 + paytable + 把数 + 种子,循环调 RunRound,统计 3.10 全部指标 + 按符号 / 按赢分区间的分布。多核并行,结果按 5.3.2 的 sim 格式输出。 命令行工具 + 报告模板 随手写一张表,100 万把 < 30 秒;同种子两次结果完全一致;换种子两次 RTP 差 < 0.1%(否则把数不够)
4 拿一份范本对准。用外包 / 买来的数学包(或公开的同类游戏参数):把它的条带喂进模拟器,跑出来的 RTP、命中率要和它报告上的数对上。 对账记录 RTP 差 < 0.2%、命中率差 < 1 个点。对不上就是开奖库理解错了规则,回第 2 步,不许进第 5 步
5 写条带生成 cmd/stripgen:按 3.11.2 实现——初始条带、爬山迭代、硬约束、经验规则、从 mid 派生 low / high。内部调第 3 步的模拟器。 命令行工具 给一组目标(如 RTP 96%、命中率 28%、免费 1/150),30 分钟内收敛出一张进容差的表
6 跑出 6 张表。产品给两种风格的 3.10 目标数字;先出 calm.midhot.mid,再各派生 low / high。每张换种子 1000 万把复核。 strips.json v1(5.3.2 格式)+ 每张表的报告 3.11.3 全部条:RTP 容差、同风格三档一致性、最高倍数不超上限、按符号 RTP 贡献没有单一符号 > 40%
7 水位选表收敛模拟。在模拟器上加一层:按 3.3 的规则读水位 → 选表 → RunRound → 更新水位,跑百万把。 收敛报告 3.7 的数:收敛到目标 RTP 要多少把、水位摆幅、三张表使用占比;给产品定切表阈值用
8 交付与接入paytable.jsongame.paytablestrips.jsongame_config.strips(ns=0 默认行),版本号对上;JSON 归档进配置仓。给游戏服同事一份「开奖库怎么调」的说明。 入库 + 归档 + 说明 游戏服用测试账号转 1 万把,SUM(total_win)/SUM(bet)table_idsim.rtp 差在统计误差内
9 上线后盯。每天看 3.7 的报表:真实 RTP 按 table_idsim.rtp、水位曲线、表使用占比、最高单把倍数。 周报 样本 ≥ 10 万把后漂移 > 0.5 个点先查开奖库版本是否一致,再查条带是否被改

顺序上 1~4 是地基,做完才有资格谈「生成条带」;很多团队跳过第 4 步直接生成,结果上线 RTP 对不上,回头找不到是开奖规则理解错还是条带有问题。第二款游戏起 2、3、5 复用,只做 1、4(如果规则有新玩法)、6、7、8。

代码位置建议:internal/slot/engine(开奖库,游戏服 import)、cmd/simulatecmd/stripgenmath/<gameId>/(paytable / strips / 报告,按 version 归档)。三个都在游戏服同一个 Go module 里,保证同一次编译同一份规则。

4. 断线

断开超过 3 秒才下分空闲分;3 秒内重连当没断过。下分之后再进:再连一次,connect proxy 里大厅调商家全额上分(余额 0 且有未完局也放行);连消播到一半必须接着播。连上先 recover,从大局表读,不重抽。

4.0 流程图

主线从「连接断了」开始:3 秒内重连不动钱;≥ 3 秒下分;人回来再连就是新上分,回不来走超时任务;两条路都汇到 1001 → 从 revealed_step 接着播。右上是「连着但等不到回包」的处理。

H. 断线:3 秒内当没断过;≥ 3 秒下空闲分;再连就是新上分;未完局永远从 round_step 接着播 大局这一行任何时候都不删、不重抽。断线只影响会话和空闲分,不影响已抽好的结果 是(原 JWT 再连 / 重新取地址) 先发 1001 1001 true:再收一条 -1010 当下一屏 false 游戏 CF 连接断了(杀进程 / 断网 / 切 后台 / 被 kick) CF ping 超时 → presence 移除。大厅轮 询发现 → session.missing_since = now。大局不动、钱不动 3 秒内重连上? connect(原 JWT) → connect proxy 见 credited → 放行,不调商家、不新建 session → presence 又有他 → missing_since 清空 ≥ 3 秒:下分(第 2 章右侧)。 cashing_out → disconnect → wallet 扣光 → 商家加款 → cashed_out 大局这一行不动:已 ack 的赢分已在 wallet 里随空闲分下走;未 ack 的留在 round_step(paid_at NULL) 人回来了吗? 超过 T(4.3 待补)没回来 → 超时任务。 动手前再查会话:有 pending / credited / cashing_out 就跳过 按 round_step 逐步派彩进 wallet( wallet_log 201/202, paid_by=2)→ round status=3 → session cashed_out→cashing_out → 再走一次下 分给商家(新 transfer 挂原 session) 。不重抽 再连(原 JWT 未过期)或回商家 /launch 再取地址 → connect proxy 见 cashed_out → 调商家全额扣 → 新 session credited → 放行。余额 0 有未 完局也放行 连上先 publish 1001 → 游戏服读 wallet · round(active=1) · round_step(revealed_step) → -1001 有未完局? -1001 只带余额。可用新 reqId 1011; 等 -1011 丢了则用原 reqId 重发 从 revealed_step 继续播(step=0 当 -1011,之后当 -1010)。播完发 1010 → 该小局 stepWin 打进当前 wallet → 下 一步仍从表取 hasNext? -1010 带余额,round status=2。这时才 能再 1011 连着,但发了 1011 / 1010 等不到 -1011 / -1010 不下分。同一 reqId 重发 1011(进行中 重推、已转完回 settled)/ 同一 step 重发 1010(旧步忽略、当前步重推)/ 发 1001。禁止换新 reqId

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 断线回来从大局表取

  1. 1011 当时已经把整条大局(含未揭示小局)写进大局表。断线不改状态、不改小局、不重抽。
  2. 断开时不下发后面几消的赢分,也不把 totalWin 算进本次下分。
  3. 回来只有一个动作:再连。3 秒内 session 还是 credited,放行不动钱;满 3 秒已下分的,connect proxy 见 cashed_out 就调商家全额上分、加游戏分、插新 session 再放行。两种都是连上后先发 1001:游戏服 SELECT 该 uid+gameId 进行中的那一行(有就是未转完),回 -1001。切到别款再切回来也是这条路:session 一直 credited,进 A 时 1001 把 A 的未完局带出来。
  4. recover 按表上的 revealedStep 返回当前步(step=0 同 -1011,之后同 -1010)。玩家继续发 1010;下一步仍从表里取,不是现场再算。
  5. 这人这款没有进行中大局才允许新的 1011。有未转完大局却再 spin → -1011 拒局。别款的未完局不挡这款的 spin(wallet 共用,但局按款独立)。
  6. 回来继续 ack。每步 1010 把这一小局 stepWin 打进这次新上分后的游戏余额。全部小局 ack 完,大局改转完。

4.3 未完局超时(口径已定,时长待补)

超时任务和人回来撞上:以会话为准。已经重连加过分、正在播的,这行还在用,超时任务碰不得。任务和 connect proxy 抢同一把 lock:connect:{ns}:{uid},拿到锁再查会话再动手。

4.4 待后续完善

5. 数据表设计

金额一律库存整数(最小货币单位,BIGINT),不用小数。流水类的金额带符号:正数加钱、负数减钱,玩家余额 = 流水求和,不靠方向字段翻译。RTP、返水率、倍率一律存 ×100 的整数(97.5% → 9750;sim.rtprebate_ratemultiplier 同精度)。所有表带 ns(商户/命名空间),查询必带。时间 UTC0。

大局表 + 小局表(永久,大局一行、每步一行,转完不删,回放用)、游戏表(固有属性 + 赔付倍数)、游戏配置表(6 张条带 + 运营改的目标 RTP、风格)、奖池表、水位快照、上下分五张(上分单 / 上下分流水 / 游戏余额 / 余额流水 / 交易科目)、用户两张(用户 / 用户游戏统计)。

5.1 大局表 round

一把一行,就是注单。每次 1011 扣注成功写一条,和小局表同一事务插入。永久保留,转完不删:转完只更新 paid_win / status / settled_at。回放、对账、RTP 报表都从这张表起步,同一大局的每一步在 5.2,round_id 关联。

字段类型说明
round_idBIGINT PK大局号,一把一号。小局表、wallet_log、客户端 1010 / recover / replay 里的 roundId 都是它
nsINT商户
uidBIGINT玩家
game_idVARCHAR(32)哪款
req_idVARCHAR(64)客户端 1011 带的。UNIQUE(ns, uid, game_id, req_id):同 reqId 重发被挡住,不双扣;1011 先按它查,查到就重推
session_idBIGINT这把属于哪张上分单,下分对账用
betBIGINT下注
table_id strips_versionVARCHAR(16) / INT用的哪张条带(如 hot.high)和当时的条带版本。审计按这两个对
target_rtpSMALLINT开局时目标 ×100(9750 = 97.5%)
water_atBIGINT开局时读到的水位快照
total_winBIGINT各小局奖额之和,开奖时就定
jackpot_winBIGINT其中爆池部分,0 为没爆
paid_winBIGINT已 ack 入账的累计。每步 1010 入账后 +stepWin。转完 = total_win
step_countSMALLINT共几小局。含连消和免费旋转,一把可能几十步
free_spinsSMALLINT本把触发的免费旋转总次数(含再触发),0 为没触发。报表看触发率用
revealed_stepSMALLINT最后一次已推给客户端的小局。1010 只认 step == revealed_step。转完 = step_count − 1
statusTINYINT1 进行中 / 2 转完 / 3 超时结算
activeTINYINT 生成列IF(status = 1, 1, NULL)UNIQUE(ns, uid, game_id, active):进行中的行 active=1 撞唯一键,插第二条未完局直接失败;转完变 NULL,唯一键不管 NULL,历史行随便多
created_at updated_at settled_atDATETIME扣注时间、最后一次 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 stepBIGINT / SMALLINT,PK哪一把、第几步。step=0 停轮,之后按发生顺序编号,连消、免费旋转都往后排。WHERE round_id=? 就是这一大局全部小局 = 一次回放的全部素材
ns uid game_id冗余,按人按款查不用 JOIN
phaseTINYINT这一步是什么:1 主转 / 2 连消 / 3 免费旋转 / 4 免费旋转里的连消。客户端按它决定播哪套动画、要不要等玩家按键
free_no free_totalSMALLINT免费旋转第几次 / 共几次(含再触发追加后的总数),给客户端画「3 / 10」。非免费步为 0
gridJSON本步盘面,符号 id 矩阵,行 × 列按游戏定义
winsJSON本步中了什么:[{symbol, count, cells, win}]cells 给客户端画连线/消除动画。没中为 []
multiplierSMALLINT本步连消倍率 ×100
step_winBIGINT本步赢分(已乘倍率)。大局 total_win = SUM(step_win)
jackpot_winBIGINT本步爆池部分,一般 0
has_nextTINYINT后面还有没有一步
revealed_atDATETIME NULL推给客户端的时间。NULL = 还没揭示
paid_atDATETIME NULL1010 入账时间。NULL = 还没入账。入账幂等看它:非空就不再加
paid_byTINYINT1 玩家 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 锁内):

  1. 读这人最新 session;不是 credited、或 game_id 不是这款 → 忽略,结束。
  2. (ns, uid, game_id, req_id) 读 round;有且 status=1 → 读 round_step(round_id, revealed_step) 重推,结束;有但 status≠1 → -1011 settled 带 wallet 余额,结束。
  3. (ns, uid, game_id, active=1) 读 round;有 → 拒局(-1011 带未完局的 roundId、revealedStep),结束。
  4. 开奖,一个事务:SELECT wallet FOR UPDATE(拿到行锁后再读一次 session,非 credited 回滚放弃;再校余额)+ 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 锁内,一个事务):

  1. 读最新 session;不是 credited(大厅正在下分 / 已下分)→ 忽略,钱不进已关的钱包。这一步在事务里、拿 wallet 行锁之后用 SELECT ... FOR SHARE 再读一次(不用快照读,避免 REPEATABLE READ 读到旧值),和大厅下分的「先关门再扣光」严格串行。
  2. 按 active=1 读 round;没有 → 忽略。校 step == revealed_step;不等 → 忽略。
  3. 读 round_step(round_id, step);paid_at 非空 → 忽略(双保险)。
  4. 余额 += step_win;round_step.paid_at = now, paid_by = 1;round.paid_win += step_win, updated_at = now。
  5. has_next:读 round_step(round_id, step+1) 回给客户端,round.revealed_step = step+1,round_step(step+1).revealed_at = now。
  6. 没有 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_idVARCHAR(32) PKfortune_ox
nameJSON多语言展示名 {"zh":"财运牛","id":"Sapi Rezeki"}
rows colsTINYINT盘面尺寸
orientationTINYINT1 竖版 / 2 横版。/launch 随 gameUrl 回给商家,客户端进游戏前锁屏幕方向;游戏列表按它分组展示
bet_optionsJSON可选注额档,库存整数 [1000, 5000, 10000, 50000]。随 -1001 betOptions 带给客户端(-1002 是踢人,不带它)
min_bet max_betBIGINT1011 校验 bet 在区间内且在 bet_options 里
paytableJSON赔付倍数,格式见 5.3.1。paytable.version 与 game_config.strips.paytableVersion 对应
statusTINYINT0 下架 / 1 上架。下架的不出现在大厅列表
created_at updated_atDATETIME

改 paytable 走审核。旧版本数据库不留,和条带一样靠归档。

5.4.2 游戏配置表 game_config

字段类型说明
ns game_idPK按商户按款一行;商户没单独配就用 ns=0 的默认行
stripsJSON这款游戏的 6 张条带,格式见 5.3.2。ns>0 行留 NULL 表示用 ns=0 的条带;填了就是该商户专用的一套数学
strips_versionINT= strips.version,拆出来方便查和比对;大局记的就是它
target_rtpSMALLINT×100,与 sim.rtp 同精度。可填区间 = [该 style 最低档 sim.rtp, 最高档 sim.rtp + 返水上限]
styleVARCHAR(16)运营选的风格
water_thresholdBIGINT切档阈值,库存整数(按多少倍平均注换算后填)
rebate_rateSMALLINT返水 ×100,系统按目标和最高档算出,运营只看
enabledTINYINT0 维护中,1011 回拒局
updated_by updated_at

后台两个页面:「游戏管理」改 gamegame_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:游戏频道不分商户,广播出去的池只能有一个数;注资来自所有商户玩家的注,爆给谁就打进谁的 wallet,钱都在我们这边,商户只看到上下分,不需要商户间结算。

字段类型说明
game_idVARCHAR(32) PK全局一行,不带 ns(与水位不同:水位可按商户分,奖池不能)
amountBIGINT当前池,库存整数。-1101 / -1001 里的 jackpot 就是它
seedBIGINT爆池后重置到的起始额(运营配,可为 0)
contributed_total paid_totalBIGINT累计注资 / 累计爆出。seed × 爆池次数 + contributed_total − paid_total = amount,对不上就是绕过事务改了
hit_count last_hit_atINT / DATETIME爆池次数 / 最近一次
updated_at

1011 事务里:UPDATE jackpot_pool SET amount = amount + bet × contribution / 10000 − jackpot_win, contributed_total += …, paid_total += jackpot_win;爆了再 amount += seedhit_count += 1。行锁天然串行,一款每秒几十把没有压力。round.jackpot_win 与 wallet_log 202 之和必须等于 paid_total。

5.4.4 水位快照 water_snapshot

字段类型说明
idBIGINT PK
ns game_id对应 Redis key
waterBIGINT快照时 Redis 值
table_idVARCHAR(16)快照时按阈值会选到的表,画「档位切换」曲线用
atDATETIME索引 (ns, game_id, at)

每分钟一行,只插不改;重启时取最新一行回填 Redis,再用之后的大局补差。保留 90 天。

5.5 上下分表

三张:上分单 session(一次进游戏一行,会话状态机就在这行上)、上下分流水 transfer(每次和商家的钱来钱往一行,幂等和对账)、游戏余额 wallet一人一行,不分款)。三张都按 (ns, uid) 归人,game_id 只是 session 上「现在在哪款」的普通字段:商家侧一个人只有一份钱、全额扣进来,我们这边也只该有一个余额;进了 A 再进 B 就是挤号换 game_id,不是第二份钱。

5.5.1 上分单 session

connect proxy 决定要上分时插一行(先 pending,商家扣完补 credited)。挤号、轮询任务、下分、超时结算都只认这一行。状态只能用条件更新往前走/launch 取地址不插行。

字段类型说明
idBIGINT PKsession_id。大局表 session_id 指它
ns uid归人
game_idINT当前在哪款。挤号时随 jti / client 一起改;轮询按它决定去哪个 slot:{gameId} 频道找人;游戏服校消息频道的 gameId 等于它
amount_inBIGINT NULL上分金额(商家按余额全部扣走的数,可为 0)。pending 时为空
statusTINYINT0 pending / 1 credited / 2 cashing_out / 3 cashed_out / 4 failed。见状态机
jtiVARCHAR(64)当前连接用的 JWT id。credited 时再连,不管 jti 相同(3 秒内重连)还是不同(换设备 / 重新取地址),处理一样:覆盖 game_id / jti / client、-1002 + disconnect(whitelist 新 client)。留它只为审计「这次连接用的哪张 token」
clientVARCHAR(64)当前 CF 连接的 client id。挤号 disconnect 时 whitelist 新的、断旧的
openTINYINT 生成列IF(status IN (0,1,2), 1, NULL)UNIQUE(ns, uid, open):一个人不分款同时最多一张开着的单,和 round.active 同一套路;Redis 锁丢了(TTL 过期、Redis 重启)也插不出第二张开着的单
connected_atDATETIME NULLconnect proxy 放行时写。轮询只看 credited 且非空的行
missing_sinceDATETIME NULL轮询首次发现不在 presence 的时刻。在了清空。≥ 3 秒触发下分
cashout_amountBIGINT下分时扣走的空闲分(0 = 钱都在未完局里)
cashout_atDATETIME NULLcashed_out 时间。未完局超时任务按它算「已下分多久」
created_at updated_at

索引:UNIQUE(ns, uid, open) 一人最多一张开着的单;(ns, uid, id) connect proxy 查「这人最新一行」、挤号、超时任务都用;(status, game_id, connected_at) 轮询按款取 expected;(status, missing_since) 3 秒任务;(status, created_at) 扫 pending 超 10 分钟。不再需要 req_id(幂等键在 transfer.id)、url_issued_at(不动钱不用退)、source(只有一种来源)。

状态机(全部条件更新,WHERE status = 旧值,影响 0 行就是别人先到了):

条件
pendingconnect proxy最新 session 是 cashed_out / failed / 没有;事务一和 transfer(status=0) 一起插
pendingcashing_out对账任务pending 超 10 分钟、商家说已扣:补事务二把钱进 wallet 后立刻转下分退回(人不在线,不 disconnect)。与 connect proxy 抢 lock:connect
pendingcreditedconnect proxy商家扣款返回成功(含 amount=0 且有未完局)。事务二
pendingfailedconnect proxy / 对账任务商家业务拒绝;或 amount=0 且无未完局;或对账查到 not_found
pendingpendingconnect proxy / 对账任务商家未知(超时 / 5xx / 熔断中)。transfer status=3,下次连上或下个退避点用同一 transfer.id 再问(2.6)
creditedcreditedconnect proxy 挤号不换状态,覆盖 game_id / jti / client、清 missing_since,publish -1002 到旧款 user 频道 + disconnect(whitelist 新 client)。同款重连、换设备、换款都是这一行
creditedcashing_out轮询任务missing_since ≥ 3 秒且本轮不在 presence
cashing_outcashed_out轮询任务disconnect → 扣空闲分 → 商家加款都成功后
cashing_outcashing_out轮询任务商家加款未知:留在 cashing_out,按 2.6 退避先查再调,靠 transfer 幂等不重复加款;业务拒绝 → transfer=2 告警人工,session 仍留 cashing_out 挡住玩家
cashed_outcashing_out超时结算任务未完局超时(4.3):剩余步派彩进 wallet 后,把这张已下分的单子再拉回 cashing_out,走一次下分(新插一条 direction=2 的 transfer 挂原 session_id),完了回 cashed_out。拿 lock:connect,人此刻连进来会被拒 retry

同一 (ns, uid) 同一时刻最多一行 pending / credited / cashing_out(生成列 open 的唯一键兜底,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。上分、下分方向都是我们主动调商家。

字段类型说明
idBIGINT PK调商家时当 req_id 传过去,商家按它幂等
ns uid game_id session_idgame_id 记这笔发生在哪款:扣注 / 派彩填 round.game_id,上下分填 session.game_id,运营科目可空。wallet 本身不分款,这里分是为了按款出报表
directionTINYINT1 上分(商家 → 游戏,connect proxy 里)/ 2 下分(游戏 → 商家,轮询任务里)
amountBIGINT NULL带符号,站在游戏钱包看:上分为正、下分为负。上分调商家前为空,商家返回后填;和 wallet_log 同一行同一个数
merchant_tx_idVARCHAR(64)商家返回的流水号
statusTINYINT0 处理中 / 1 成功 / 2 业务拒绝(不重试)/ 3 未知(超时、5xx、验签不过,按 2.6 重试)
retry_count next_retry_atSMALLINT / DATETIME NULL重试次数与下次退避时间点(2.6.2)。status∈{0,3} 才有意义;索引 (status, next_retry_at) 给重试任务扫
errorVARCHAR(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 uidPK一人一行,不分款。A 款扣的注、B 款赢的钱都在这一个数上
balanceBIGINT可下注的游戏分 = 空闲分。未 ack 的连消赢分不在这里,在 round_step 里(paid_at 为空的那些步)
versionINT乐观锁,或直接靠 (ns, uid) 行锁
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,对不上就是有地方绕过它改了钱。

字段类型说明
idBIGINT PK
ns uid game_id
subject_codeSMALLINT交易科目,= 5.5.5 subject.code正数加钱、负数减钱,和 amount 同号;三位数,百位 = 类别;成对的科目数字一样正负相反(101 上分 / −101 下分,201 派彩 / −201 扣注),和事件 code 一个习惯
amountBIGINT带符号:正数加钱、负数减钱。扣注 −1000、派彩 +2500。SUM(amount) 就是余额,不用按科目翻符号
balance_before balance_afterBIGINTafter = before + amount
ref_id ref_stepBIGINT / SMALLINT(与 round_step.step 同型)指向来源:扣注 = round_id;派彩 = round_id + step;上分 / 下分 = transfer.id;返水 = session_id;调账 = 后台单号
session_idBIGINT发生在哪次会话里,下分对账按它汇总
created_atDATETIME

索引:(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),不等直接拒,不用另设方向字段;amount = 0 的变动(商家返回 0、下分时 wallet 已是 0)不写 wallet_log,痕迹在 transfer / session 上。

字段类型说明
codeSMALLINT PKwallet_log.subject_code 指它。三位数,符号 = 钱的方向,百位 = 类别
keyVARCHAR(32) UNIQUE代码里按它引用,不在代码里写数字:deposit withdraw payout bet
nameJSON多语言名,客户端账单和后台报表显示
categoryTINYINT= ABS(code) DIV 100。1 商家往来(上分 / 下分)/ 2 游戏(扣注 / 派彩 / 爆池)/ 3 运营(返水 / 赠送 / 调账)。日终按它三段对账:商家往来净额 = 游戏净额 + 运营净额 + 余额变化
in_turnoverTINYINT算不算有效流水。只有 bet 为 1,返水按它挑
show_playerTINYINT玩家账单里显示不显示。调账、内部冲正为 0
statusTINYINT0 停用(历史行仍能显示)/ 1 启用

初始科目:

codekey类别流水来源 ref
101deposit 上分商家往来transfer.id
−101withdraw 下分商家往来transfer.id
201payout 派彩游戏round_id + step
−201bet 扣注游戏round_id
202jackpot 爆池游戏round_id + step。和 payout 分开记,奖池对账用
301rebate 返水运营session_id。预留:默认返水由商家发真钱(3.6),不进游戏分、不产生这行;商户要求返水进游戏分时才用
302bonus 赠送运营活动单号
303adjust_in 调账加运营后台工单号
−303adjust_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 用户 playeruser 是保留字,不用)

字段类型说明
uidBIGINT PK我们的 id。自增或雪花。全站唯一,跨商家不重复
nsINT属于哪个商家
merchant_uidVARCHAR(64)商家侧用户 id。UNIQUE(ns, merchant_uid)。上分时按这两键 upsert
nickname avatarVARCHAR商家上分时带来,可空。只用于排行榜/大奖播报展示
currencyCHAR(3)IDR / MXN。跟商家走,一个用户一种,不换
langVARCHAR(8)客户端文案语言,商家带或按 ns 默认
vip_levelTINYINT商家带来。只影响返水档,不影响任何一张表、不影响水位
statusTINYINT1 正常 / 2 冻结。冻结:上分拒、connect proxy 拒、1011 拒局,已在局内的 ack 照常(把已抽的播完),不下分
risk_tagVARCHAR(32)风控标记(脚本、多账号等),只标不动盘
first_seen_atDATETIME第一次上分
last_seen_atDATETIME最近一次 connect proxy 放行
created_at updated_at

索引:UNIQUE(ns, merchant_uid)(ns, status)。不存密码、手机、邮箱、真钱余额——都是商家的事。

5.6.2 用户游戏统计 player_game

一人一款一行,每把 1011 转完后累加。给返水、风控、后台看人用,不参与抽奖。

字段类型说明
ns uid game_idPK
spinsINT累计把数。若将来做新手期,选表条件看它
total_bet total_winBIGINT累计下注 / 派彩。个人 RTP = total_win / total_bet,只给后台看
max_win max_win_xBIGINT / INT单把最大赢、最大倍数。大奖播报和风控用
jackpot_hitsINT爆池次数
sessionsINT上分次数
first_played_at last_played_atDATETIME

更新时机:大局 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 几条关系

6. 四块怎么咬在一起

完整顺序见 第 0 章。这里只收一句:改钱的只有上分、下分、扣注、派彩、返水。前四项要 req_id / round_id;返水跟流水对账。大局表永久保留,转完改状态,一人一款最多一条进行中。

7. 建议完善顺序

  1. 先定谁做轮带。这是游戏数学的活,不是程序能顺手做的。有游戏数学师就他做;没有,第一款游戏买或外包数学包(1 份赔付表 + 6 份条带 + 每份的百万把模拟报告),拿到范本。
  2. 同时自己写模拟工具(3.11 cmd/stripgen):输入符号表、赔付倍数、连消规则、目标指标,随机生成条带 → 百万把模拟 → 对比目标 → 调格数 → 循环到进容差,输出条带 JSON + 报告,写进 game_config.strips。验收外包的表也用它复算。第二款起照范本指标自己排。
  3. 拿到 6 张表后跑「水位选表」收敛模拟;后台先能填目标 RTP、选风格。
  4. 产品补第 3.9 节:切表阈值、返水上限、1011 耗时上限。
  5. -1011 / -1010 盘面字段;未完连消超时时长。