Seamless Wallet 的五个回调:一次对账差 37 万的复盘
一、一个真实的故障
某个平台上线第三个月,财务对账时发现一个数:
玩家侧余额总和: 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 内,否则高峰期会大量超时重试,放大成雪崩。
对策:
bet接口不查外部服务(不调风控、不查黑名单)- 所有风控判断异步化(先扣款,后审核,可疑再冻结)
- 数据库连接池预热
③ /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"
}
用于客服补款、活动返利、争议单处理。
这个是唯一可以由人工触发的接口,所以必须:
- 双人复核(发起 + 审批)
- 全量审计日志(谁、何时、为什么、改了多少)
- 单笔限额(超过 X 需要上级批准)
- 不允许负数余额(调账前校验)
四、四个工程约束
① 幂等:用数据库约束,不要用应用层判断
| 接口 | 幂等键 |
|---|---|
/bet | betId |
/settle | betId + status 状态机 |
/rollback | rollbackId |
/adjustment | adjustmentId |
统一原则:
-- 所有幂等键都必须是主键或唯一索引
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 已经到了。
所以:
- 所有接口必须能处理乱序(
settle先到、bet后到) settle找不到bet时不要报错,先缓存,等bet到了再对账- 超时不要返回 500,返回明确的业务错误(
insufficient_balance),避免供应商无限重试
// 错误示例
{ "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')
);
}
三个必须:
- HMAC-SHA256,不要用 MD5(可碰撞)
- 包含 timestamp 并校验时间窗(防重放,通常 ±5 分钟)
timingSafeEqual,不要用===(时序攻击)
五、状态机设计
所有注单的状态转换必须收敛到这张图:
┌─────────────┐
│ placed │
└──────┬──────┘
│
┌──────────────┼──────────────┐
↓ ↓ ↓
┌────────────┐ ┌──────────┐ ┌────────────┐
│ settled │ │cancelled │ │ dispute │
└────────────┘ └──────────┘ └────────────┘
│ │ │
└─────────────────┴──────────────┘
↓
┌─────────┐
│ final │ (不可再变)
└─────────┘
规则:
| 当前状态 | 允许的动作 |
|---|---|
placed | settle / 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_,对账时过滤
很多人上线三个月后才发现,对账差异里有一半是上线前的测试数据。
七、七个坑(按踩中概率排序)
| # | 坑 | 后果 | 对策 |
|---|---|---|---|
| 1 | rollback 没用 rollbackId 幂等 | 重复退款,直接亏钱 | 用 rollbackId 做主键 |
| 2 | 用浮点数存余额 | 对账差几分钱,查不出来 | 整数存最小单位 |
| 3 | /bet 里同步查风控 | 超时重试雪崩 | 风控异步化 |
| 4 | check-then-act 做幂等 | 并发下重复扣款 | 数据库唯一约束 |
| 5 | 已结算注单被 rollback | 负余额、玩家投诉 | 状态机校验,拒绝 |
| 6 | 测试单混进生产对账 | 对账永远差几百块 | playerId 前缀隔离 |
| 7 | 不做 T+1 对账 | 亏钱三个月后才发现 | 每日跑批 |
第 1 条和第 4 条,是开头的 37 万的直接原因。
八、结语
Seamless Wallet 看起来只是五个 HTTP 接口,但它的本质是:
你在把"钱"的控制权暴露给第三方,让他们通过 HTTP 请求来操作你的账本。
这意味着:
- 每一次请求都可能是重复的(重试机制)
- 每一次请求都可能是乱序的(网络抖动)
- 每一次请求都可能来自攻击者(伪造签名)
所以你的接口必须同时是:幂等的、串行安全的、签名验证的。
这三件事做好,对账就不会出问题。 做不好,就是开头那个故事——37 万,一行代码,三天排查。
文中代码为示意,实际实现需结合你的技术栈与供应商的具体规范。对接前务必在沙箱环境跑完全部幂等与并发用例。