Seamless Wallet 的五个回调:一次对账差 37 万的复盘

2026-11-12 · vigboard

一、一个真实的故障

某个平台上线第三个月,财务对账时发现一个数:

玩家侧余额总和:    2,847,300
平台侧账面余额:    2,473,900
差额:                373,400

37 万。

不是被黑,不是被薅,是自己的代码多退了钱。

根因查了三天,最后定位到一行:

// 错误实现
app.post('/api/game/rollback', async (req, res) => {
  const { betId } = req.body;
  await db.query('UPDATE bets SET status = "cancelled" WHERE bet_id = ?', [betId]);
  await refund(betId);   // ← 这里没有判断是否已经退过
  res.json({ ok: true });
});

游戏方在一次网络抖动中重复推送了 1,847 次 rollback,每一次都触发了一次退款。

37 万就是这么没的。

这篇文章从这个坑出发,把 Seamless Wallet 的五个接口逐一拆开。

二、先分清两种钱包模型

在写第一行代码之前,你必须确认供应商用的是哪种:

模型余额在哪资金流向典型场景
Seamless(无缝)你的服务器每注实时回调扣款/派彩主流,90% 的电子游戏
Transfer(转账)游戏方服务器玩家先转入一笔,玩完转回彩票、部分亚洲供应商

Seamless 的调用链

玩家点击旋转
    ↓
游戏方服务器(持有游戏逻辑与 RNG)
    ↓  HTTP POST
你的 API:/bet      → 扣款
    ↓
游戏方结算
    ↓  HTTP POST
你的 API:/settle   → 派彩
    ↓
你的前端轮询 / WebSocket → 更新余额

关键认知:你的服务器是"银行",游戏方是"商户"。每一次刷卡,商户都要问银行"能不能扣、扣多少"。

Transfer 的调用链

玩家点击"转入游戏"
    ↓  你的 API 扣款、记账
游戏方账户 +1000
    ↓  玩家在游戏里玩
游戏结束
    ↓  游戏方回调你:转回 850
你的 API 加款

Transfer 简单得多,但用户体验差(要手动转进转出),且资金在游戏方手里有滞留风险。

下面全部按 Seamless 讲。

三、五个回调逐一拆解

不同供应商的命名略有差异,但语义恒定。下面用最常见的叫法。

① /balance — 查余额

POST /api/game/balance
{
  "playerId": "u_88213",
  "currency": "USDT",
  "token": "eyJhbGci..."
}
{ "balance": 1250.00, "currency": "USDT" }

看起来最简单,但有两个坑:

坑 1:并发查询

玩家同时开 3 个游戏窗口,会有 3 个 /balance 并发打进来。如果你这里做了写操作(比如懒创建账户),会产生死锁或重复创建。

对策:/balance 必须是只读事务,账户不存在直接返回 balance: 0,不要顺手创建。

坑 2:余额精度

// ❌ 浮点
let balance = 1250.10;
balance -= 0.10;        // 1249.9999999999998

// ✅ 整数(最小单位)
let balance = 125010;   // 以 0.01 为单位
balance -= 10;          // 125000

这个坑不会立刻爆,但会在三个月后变成对账差几分钱的悬案,然后你花一周去查。

② /bet — 下注扣款

POST /api/game/bet
{
  "betId": "b_7f3a9c21",
  "roundId": "r_20261112_0042",
  "playerId": "u_88213",
  "amount": 10.00,
  "gameCode": "vs20fortunetiger",
  "currency": "USDT",
  "timestamp": 1762905600123,
  "sign": "a3f9..."
}
{ "ok": true, "balance": 1240.00, "betId": "b_7f3a9c21" }

这是最关键的一个接口。三条硬要求:

要求 1:幂等(同一个 betId 只扣一次)

-- 表结构
CREATE TABLE bets (
  bet_id      VARCHAR(64) PRIMARY KEY,   -- ← 主键即幂等键
  round_id    VARCHAR(64) NOT NULL,
  player_id   VARCHAR(64) NOT NULL,
  amount      DECIMAL(18,2) NOT NULL,
  status      ENUM('placed','settled','cancelled') NOT NULL,
  created_at  BIGINT NOT NULL,
  UNIQUE KEY uk_bet (bet_id)
);
// ✅ 正确:用主键冲突做幂等
try {
  await db.query(
    'INSERT INTO bets (bet_id, round_id, player_id, amount, status, created_at) VALUES (?,?,?,?,?,?)',
    [betId, roundId, playerId, amount, 'placed', Date.now()]
  );
} catch (e) {
  if (e.code === 'ER_DUP_ENTRY') {
    // 已经处理过,直接返回当前余额
    return res.json({ ok: true, balance: await getBalance(playerId), betId });
  }
  throw e;
}

注意:幂等不是"查一下存不存在,不存在再插入"。

// ❌ 错误:check-then-act 有竞态窗口
const exists = await db.query('SELECT 1 FROM bets WHERE bet_id = ?', [betId]);
if (!exists) {
  await db.query('INSERT INTO bets ...');   // ← 两个并发请求可能都到这里
}

必须用数据库唯一约束兜底,不能靠应用层判断。

要求 2:余额校验与扣减在同一事务

BEGIN;
  -- 1. 锁定玩家账户行
  SELECT balance FROM accounts WHERE player_id = ? FOR UPDATE;

  -- 2. 余额不足则回滚
  -- 3. 扣减
  UPDATE accounts SET balance = balance - ? WHERE player_id = ? AND balance >= ?;
COMMIT;

AND balance >= ? 这个条件不能省——它让数据库在余额不足时返回 affectedRows = 0,避免应用层判断的竞态。

要求 3:响应时间 < 500ms

游戏方的超时阈值通常是 3–5 秒,但你的 P99 必须控制在 500ms 内,否则高峰期会大量超时重试,放大成雪崩。

对策:

③ /settle — 派彩

POST /api/game/settle
{
  "betId": "b_7f3a9c21",
  "winAmount": 95.00,
  "jackpotAmount": 0,
  "currency": "USDT",
  "sign": "..."
}
{ "ok": true, "balance": 1335.00 }

核心:status 只能从 placed → settled,不能重复结算。

UPDATE bets
SET status = 'settled', win_amount = ?, settled_at = ?
WHERE bet_id = ? AND status = 'placed';
-- affectedRows = 0 → 说明已经结算过或已取消

这个 AND status = 'placed' 就是幂等保护。

如果一个 bet 已经被 rollback 取消,再收到 settle,应该返回错误而不是派彩:

{ "ok": false, "error": "bet_already_cancelled" }

④ /rollback — 撤销注单(最容易亏钱的地方)

POST /api/game/rollback
{
  "betId": "b_7f3a9c21",
  "rollbackId": "rb_9e2d",
  "amount": 10.00,
  "reason": "round_cancelled",
  "sign": "..."
}

这就是开头那 37 万的来源。

三种常见场景会触发 rollback:

场景说明
游戏回合作废荷官发错牌、系统判定异常
玩家断线超时下注后未完成回合,回滚退款
供应商侧故障结算失败,回滚重来

正确实现必须满足两条:

要求 1:按 rollbackId 幂等,不是按 betId

CREATE TABLE rollbacks (
  rollback_id VARCHAR(64) PRIMARY KEY,   -- ← 幂等键
  bet_id      VARCHAR(64) NOT NULL,
  amount      DECIMAL(18,2) NOT NULL,
  created_at  BIGINT NOT NULL
);
async function handleRollback({ rollbackId, betId, amount }) {
  return await db.transaction(async (trx) => {
    // 1. 幂等:rollbackId 唯一
    try {
      await trx.query(
        'INSERT INTO rollbacks (rollback_id, bet_id, amount, created_at) VALUES (?,?,?,?)',
        [rollbackId, betId, amount, Date.now()]
      );
    } catch (e) {
      if (e.code === 'ER_DUP_ENTRY') {
        return { ok: true, note: 'already_rolled_back' };
      }
      throw e;
    }

    // 2. 状态校验:只能撤销 placed 状态的注单
    const r = await trx.query(
      `UPDATE bets SET status = 'cancelled', cancelled_at = ?
       WHERE bet_id = ? AND status = 'placed'`,
      [Date.now(), betId]
    );

    if (r.affectedRows === 0) {
      // 已经结算或已取消,不回滚资金
      return { ok: true, note: 'not_rollbackable' };
    }

    // 3. 退款
    await trx.query(
      'UPDATE accounts SET balance = balance + ? WHERE player_id = (SELECT player_id FROM bets WHERE bet_id = ?)',
      [amount, betId]
    );

    return { ok: true, refunded: amount };
  });
}

关键点:rollbackId 是幂等键,betId 是业务键。 用 betId 做幂等会漏——因为同一个注单可能被多次回滚(分次回滚),也可能回滚后重新下单。

要求 2:已结算的注单不能被 rollback

这是很多人写错的地方。

一个注单已经 settled(玩家赢了钱),供应商又推一条 rollback 说"这局作废"。

正确做法:拒绝 rollback,返回错误让供应商人工处理。

{ "ok": false, "error": "bet_already_settled" }

为什么不能自动处理? 因为已经派彩的钱可能已经被玩家提走。自动回滚会造成负余额和纠纷。

让供应商走人工流程,是他们的问题,不是你的。

⑤ /adjustment — 人工调账

POST /api/game/adjustment
{
  "adjustmentId": "adj_771",
  "playerId": "u_88213",
  "amount": -50.00,
  "reason": "bonus_reclaim",
  "operator": "cs_agent_12"
}

用于客服补款、活动返利、争议单处理。

这个是唯一可以由人工触发的接口,所以必须:

四、四个工程约束

① 幂等:用数据库约束,不要用应用层判断

接口幂等键
/betbetId
/settlebetId + status 状态机
/rollbackrollbackId
/adjustmentadjustmentId

统一原则:

-- 所有幂等键都必须是主键或唯一索引
ALTER TABLE xxx ADD UNIQUE KEY uk_xxx (幂等键);

应用层永远会有竞态窗口,只有数据库约束是原子的。

② 并发:同一玩家串行化

玩家的余额是一个串行资源。两个并发请求同时读余额 100、各自扣 80,最后变成 -60。

// 用 Redis 分布式锁,按 playerId 加锁
const lockKey = `lock:player:${playerId}`;
const locked = await redis.set(lockKey, '1', 'NX', 'PX', 3000);
if (!locked) {
  return res.status(429).json({ error: 'busy' });   // 让供应商重试
}
try {
  // 处理业务
} finally {
  await redis.del(lockKey);
}

或者用数据库行锁(SELECT ... FOR UPDATE),效果一样,但要注意事务边界。

关键:锁的粒度是 playerId,不是全局。 全局锁会在高峰期把整个系统串成串行。

③ 超时与重试:你必须容忍乱序

供应商的重试策略通常是:

第 1 次:立即
第 2 次:+1s
第 3 次:+3s
第 4 次:+9s
第 5 次:+27s

这意味着一笔 bet 可能在 40 秒后才被重试,而此时 settle 已经到了。

所以:

// 错误示例
{ "ok": false, "error": "insufficient_balance", "retryable": false }

// 正确示例
{ "ok": false, "error": "system_busy", "retryable": true }

告诉供应商"能不能重试",比返回一个 500 让他们猜要好得多。

④ 签名:HMAC,不是 MD5 拼接

const crypto = require('crypto');

function verifySignature(params, secret) {
  // 1. 去掉 sign 字段,其余参数按 key 字典序排序
  const keys = Object.keys(params).filter(k => k !== 'sign').sort();
  const payload = keys.map(k => `${k}=${params[k]}`).join('&');

  // 2. HMAC-SHA256
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  // 3. 恒定时间比较,防时序攻击
  return crypto.timingSafeEqual(
    Buffer.from(params.sign, 'hex'),
    Buffer.from(expected, 'hex')
  );
}

三个必须:

五、状态机设计

所有注单的状态转换必须收敛到这张图:

                  ┌─────────────┐
                  │   placed    │
                  └──────┬──────┘
                         │
          ┌──────────────┼──────────────┐
          ↓              ↓              ↓
   ┌────────────┐  ┌──────────┐  ┌────────────┐
   │  settled   │  │cancelled │  │  dispute   │
   └────────────┘  └──────────┘  └────────────┘
        │                 │              │
        └─────────────────┴──────────────┘
                       ↓
                  ┌─────────┐
                  │  final  │   (不可再变)
                  └─────────┘

规则:

当前状态允许的动作
placedsettle / rollback
settled只能 dispute(人工介入)
cancelled无(终态)
dispute人工处理

任何 "settled → cancelled" 的自动转换都必须拒绝。

六、对账体系

前面的技术工作,最后都要落到对账上。

T+1 全量对账

每天凌晨 2:00 跑批:

1. 拉取供应商昨日全部注单(他们有报表 API)
2. 与本地 bets 表按 betId 全量比对
3. 差异分类:
   - 只有供应商有 → 漏单(你在某次回调中失败了)
   - 只有你有 → 脏单(可能是测试数据或重复推送)
   - 金额不一致 → 严重,立刻告警
4. 差异单进入人工处理队列

三个必须记录的数

指标说明
总流水(turnover)所有 bet 金额之和,这是结算基础
总派彩(payout)所有 settle 金额之和
净收入(GGR)turnover − payout,这是你的毛利

如果这三个数每天对不上,你的账就是假的。

一个容易忽略的点:测试单隔离

❌ 错误:测试环境的注单混在生产库
✅ 正确:测试 playerId 统一前缀 test_,对账时过滤

很多人上线三个月后才发现,对账差异里有一半是上线前的测试数据。

七、七个坑(按踩中概率排序)

#坑后果对策
1rollback 没用 rollbackId 幂等重复退款,直接亏钱用 rollbackId 做主键
2用浮点数存余额对账差几分钱,查不出来整数存最小单位
3/bet 里同步查风控超时重试雪崩风控异步化
4check-then-act 做幂等并发下重复扣款数据库唯一约束
5已结算注单被 rollback负余额、玩家投诉状态机校验,拒绝
6测试单混进生产对账对账永远差几百块playerId 前缀隔离
7不做 T+1 对账亏钱三个月后才发现每日跑批

第 1 条和第 4 条,是开头的 37 万的直接原因。

八、结语

Seamless Wallet 看起来只是五个 HTTP 接口,但它的本质是:

你在把"钱"的控制权暴露给第三方,让他们通过 HTTP 请求来操作你的账本。

这意味着:

所以你的接口必须同时是:幂等的、串行安全的、签名验证的。

这三件事做好,对账就不会出问题。 做不好,就是开头那个故事——37 万,一行代码,三天排查。


文中代码为示意,实际实现需结合你的技术栈与供应商的具体规范。对接前务必在沙箱环境跑完全部幂等与并发用例。