openlivegame · 商户对接文档(Dev)
游戏供应商对接与游戏数据监控平台。本文档定义了商户与 openlivegame 之间的 双向 HTTP 协议:商户通过 Provider API 获取游戏启动链接, openlivegame 通过 Seamless Wallet 回调实时完成余额变动。
| Provider API(商户 → openlivegame) | https://merchant.caca789.com/provider/v1 |
| 玩家游戏入口(gameURL 落地) | https://pp-client.caca789.com |
| Wallet 回调(openlivegame → 商户) | 由商户提供 callback_url,例如 https://merchant-wallet.example.com/wallet |
概述 #
openlivegame 采用 Seamless Wallet(无缝钱包)集成模式。商户保留玩家资金账户的唯一真实来源, 玩家在游戏中的每一次下注 / 派奖 / 退款都由 openlivegame 实时回调商户钱包接口完成扣款与加款, openlivegame 自身不托管任何真实资金。
① 商户 → openlivegame
商户调用 Provider API 获取玩家游戏启动 URL,将玩家导向游戏客户端。
② openlivegame → 商户
openlivegame 在玩家下注结算过程中回调商户 Wallet API 完成资金变动。
③ 双向安全
两侧均使用统一的 MD5 签名算法 + 商户密钥验证请求真实性。
系统架构 #
整体交互分为同步链路(商户主动)与异步链路(openlivegame主动回调):
┌──────────────┐ ① POST /provider/v1/gameurl ┌──────────────┐
│ │ ────────────────────────────────────────▶ │ │
│ │ ◀──────────────────────────────────────── │ │
│ │ 返回 gameURL (含 JSESSIONID) │ │
│ 商户 │ │ openlivegame │
│ Merchant │ ② 玩家跳转游戏客户端 (WS) │ Platform │
│ │ │ │
│ │ ◀──────────────────────────────────────── │ │
│ │ ③ POST {callback_url}/authenticate │ │
│ │ ④ POST {callback_url}/bet / result ... │ │
│ │ ────────────────────────────────────────▶ │ │
└──────────────┘ 返回 cash / transactionId └──────────────┘
商户上下文字段
openlivegame 为每个商户保存以下关键配置,请在对接前向 openlivegame 索取或提交:
| 字段 | 用途 | 来源 |
|---|---|---|
secureLogin | 商户 API 身份标识,Provider API 中用来定位商户 | openlivegame 分配 |
secretKey | 双向签名密钥,禁止泄露 | openlivegame 分配 |
callback_url | 商户 Seamless Wallet 回调基础 URL,例如 https://merchant-wallet.example.com/wallet | 商户提供 |
ip_whitelist | 调用 Provider API 的出口 IP 白名单(逗号分隔,可选) | 商户提供 |
currency | 商户使用的币种(须在平台货币支持列表内) | 商户提供 |
完整对接时序 #
token(建议 5–30 分钟有效期),
并将该 token 与玩家绑定,后续 Authenticate 回调凭此查询玩家。
POST /provider/v1/gameurl
传入 secureLogin、token、externalPlayerId、currency、gameId(可选),
以及按签名规则计算得到的 hash。
POST {callback_url}/authenticate
openlivegame 用同一份 token 反向询问商户,商户必须返回 userId(需 == externalPlayerId)、
currency 和当前余额 cash。
gameURL
URL 中附带平台会话 JSESSIONID,商户将玩家 302 跳转至该 URL 即可进入游戏。
/bet / /result / /refund),
商户执行账户变动后返回最新余额。
POST {callback_url}/balance 拉取最新余额。
货币支持 #
当前平台支持以下币种。gameurl 的 currency 入参与
authenticate 回调返回的 currency 都必须取自此列表:
| currency | 名称 | 符号 | 说明 |
|---|---|---|---|
USD | 美元 | $ | 基准币种 |
EUR | 欧元 | € | — |
BRL | 巴西雷亚尔 | R$ | — |
IDR | 印尼盾 | Rp | — |
INR | 印度卢比 | ₹ | — |
USDT | 泰达币 | $ | 按 1:1 锚定 USD 处理 |
- 币种需在 openlivegame 平台与游戏上游双侧开通后方可投运(限红、投注档位均按币种独立配置)。
传入未开通的
currency可能导致进入游戏后限红 / 余额显示异常,请勿自行尝试列表外币种。 - 需要接入新币种时请提前联系 openlivegame 运营开通,开通后此列表同步更新。
- 一个
externalPlayerId终身绑定一种币种(详见 gameurl 的「一用户一币种」约束)。
Provider API · openlivegame 提供给商户 #
基础地址:https://merchant.caca789.com/provider/v1。
下文各端点路径(如 /gameurl)均为相对于该基础地址的相对路径,
实际请求 URL 形如 https://merchant.caca789.com/provider/v1/gameurl。
所有接口均为 HTTP POST(除 healthcheck)、
Content-Type: application/x-www-form-urlencoded。
签名字段名为 hash,算法见 签名算法。
返回格式统一为 JSON,error 为字符串错误码,"0" 表示成功。
/provider/v1/* 前缀,本环境只接受该前缀,
历史路径一律不可用。
商户侧玩家发起进入游戏请求时调用此接口。openlivegame 将通过商户预留的 callback_url 反向回调
authenticate 验证玩家身份,验证通过后返回一个附带 JSESSIONID 的游戏客户端 URL。
请求参数
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
secureLogin | string | 必填 | 商户 API 身份标识 |
token | string | 必填 | 商户侧生成的玩家一次性 token,将透传给 authenticate 回调 |
externalPlayerId | string | 必填 | 商户侧玩家唯一 ID,格式 ^[A-Za-z0-9_\-]{1,64}$。一个 externalPlayerId 只能绑定一种 currency;多币种用户请为每个币种使用不同的 externalPlayerId(详见下方) |
currency | string | 必填 | ISO 币种代码,取值见 货币支持。必须与 authenticate 回调返回的 currency 一致,否则返回 error=20 |
hash | string | 必填 | 请求签名,MD5 小写十六进制 |
gameId | string | 可选 | 游戏机台代码(即 getcasinogames 返回的 gameId);为空则进入游戏大厅,非空则直接进入指定机台。无论是否传值都参与签名(见下方签名说明) |
language | string | 可选 | 语言代码,默认 en。建议始终显式传值:不传时openlivegame按 language=en 参与签名校验(见下方签名说明)。用户在游戏内设置过的语言偏好会覆盖此字段 |
country | string | 可选 | ISO 国家代码 |
platform | string | 可选 | 平台标识(mobile / desktop 等) |
lobbyUrl | string | 可选 | 玩家退出游戏后跳转回的大厅 URL |
lobby | string | 可选 | 传 1 表示只进大厅 / 分类列表(按 gameId 对应机台的类型列桌,不绑定具体桌台);不传则直接进入 gameId 对应桌台。只在传 1 时参与签名,请勿传 0 |
secureLogin/gameId/token/externalPlayerId/currency恒参与签名——gameId不传时也要以空值(gameId=)参与拼接。language恒参与:不传时openlivegame按language=en重算签名,因此要么显式传 language,要么把language=en计入签名。推荐显式传。country/platform/lobbyUrl仅在非空时参与;lobby仅在值为1时参与。空值字段请不要发送、也不要计入签名。
响应字段
| 字段 | 类型 | 说明 |
|---|---|---|
error | string | 错误码,"0" 表示成功 |
description | string | 结果描述 |
gameURL | string | 成功时返回的游戏客户端 URL,仅当 error="0" 存在 |
请求示例
curl -X POST https://merchant.caca789.com/provider/v1/gameurl \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "secureLogin=merchant001" \
-d "token=player-token-xyz" \
-d "externalPlayerId=player-001" \
-d "currency=USD" \
-d "gameId=545" \
-d "language=en" \
-d "hash=e768a4a5f34aec8263db58b7d2775f30"
# 示例 hash 以 secretKey=mysecretkey 计算,可用来自校验你的签名实现
成功响应
{
"error": "0",
"description": "OK",
"gameURL": "https://pp-client.caca789.com/desktop/baccarat/?JSESSIONID=sess-abc&table_id=545&lang=en&..."
}
失败响应示例
{
"error": "5",
"description": "invalid signature"
}
userId 必须与本次请求中的 externalPlayerId 严格相等,
否则 gameurl 返回 error=4;返回的 currency 必须与本次请求的 currency
一致(大小写不敏感),否则返回 error=20。openlivegame 不做任何容错匹配。
externalPlayerId 只允许绑定一种 currency。
首次进入游戏时 (externalPlayerId, currency) 会被固化,后续若用相同 externalPlayerId
传入不同的 currency,将返回 error=20 拒绝进入。
如果商户侧同一个自然人用户拥有多个币种账户(例如一个用户同时持有 USD 与 EUR 钱包), 商户必须为每一种币种提供一个不同的
externalPlayerId,
哪怕在商户系统中它们对应同一个真实用户。
推荐命名规范:
{原用户ID}_{币种},例如 u12345_USD / u12345_EUR。
商户自行在内部维护「自然人 ↔ 多币种 externalPlayerId」的映射关系。
返回当前 openlivegame 平台已启用的机台清单。商户可用于构建自己的游戏大厅页。
请求参数
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
secureLogin | string | 必填 | 商户 API 身份标识 |
hash | string | 必填 | 请求签名 |
gameType | string | 可选 | 按游戏类型过滤,如 roulette / baccarat / blackjack。仅非空时参与签名 |
响应字段
| 字段 | 类型 | 说明 |
|---|---|---|
error | string | 错误码 |
description | string | 结果描述 |
gameList[] | array | 机台列表 |
gameList[].gameId | string | 机台代码,用作 gameurl 的 gameId 入参;也是钱包回调中的 tableCode |
gameList[].operatorGameId | string | 上游游戏 ID(供应商平台的游戏 ID,跨账号一致) |
gameList[].gameName | string | 机台展示名 |
gameList[].gameType | string | 游戏类型 |
gameList[].typeDescription | string | 游戏类型描述(大厅分类) |
gameList[].vendorType | string | 供应商类型(pp / evo),供商户按供应商分组展示;不需要可忽略 |
gameList[].status | string | 机台状态,恒为 open(接口只返回启用机台) |
成功响应示例
{
"error": "0",
"description": "OK",
"gameList": [
{
"gameId": "545",
"operatorGameId": "101",
"gameName": "Speed Baccarat A",
"gameType": "baccarat",
"typeDescription": "baccarat",
"vendorType": "pp",
"status": "open"
},
{
"gameId": "227",
"operatorGameId": "221",
"gameName": "Auto Roulette",
"gameType": "roulette",
"typeDescription": "roulette",
"vendorType": "pp",
"status": "open"
}
]
}
按 roundId(即钱包回调中携带的业务局号)换取一个该局报表页的一次性访问 URL,
供商户客服核单 / 玩家查看本局详情。URL 内附带短期有效 token,过期需重新调用本接口。
请求参数
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
secureLogin | string | 必填 | 商户 API 身份标识 |
roundId | string | 必填 | 业务局号(钱包 bet/result/refund 回调中的 roundId,16 位纯数字) |
hash | string | 必填 | 请求签名 |
响应字段
| 字段 | 类型 | 说明 |
|---|---|---|
error | string | 错误码;"4" 表示本商户名下无此局,"7" 表示 roundId 格式非法 |
description | string | 结果描述 |
url | string | 报表页一次性 URL,仅当 error="0" 存在 |
成功响应示例
{
"error": "0",
"description": "OK",
"url": "https://pp-client.caca789.com/reports/545/index.html?token=..."
}
error=4(round not found)。
探活接口,无需参数、无需签名。返回 error="0" 表示服务正常。
curl https://merchant.caca789.com/provider/v1/healthcheck
# =>
{ "error": "0", "description": "OK" }
Seamless Wallet · 商户提供给 openlivegame #
基础地址:商户预留的 callback_url(例如 https://merchant-wallet.example.com/wallet)。
下文各端点路径(如 /authenticate)均为相对于该 callback_url 的相对路径,
实际请求 URL 形如 https://merchant-wallet.example.com/wallet/authenticate。
Dev 环境下 openlivegame 出口 IP 由出网网关决定,如商户在自己的 WAF / 防火墙上对入站做了白名单限制, 请向 openlivegame 索取最新的出口 IP 列表。
请求格式
- Method:
POST - Content-Type:
application/x-www-form-urlencoded - 签名字段:
hash(MD5,对全部非 hash 字段计算) - 超时时间:
/bet5 秒;其余接口 10 秒(openlivegame 侧 client timeout)
响应格式
- Content-Type:
application/json - HTTP Status: 业务错误也请使用
200 OK,错误信息写入error字段 - error 字段: 整型(注意:与 Provider API 的字符串错误码不同)
- 金额字段: JSON number,最多 2 位小数
bet / result / refund 三个接口必须基于 reference 字段实现幂等。
同一 reference 的重复请求必须返回 同一条流水(相同 transactionId 和 cash),
禁止重复扣款或重复加款。重复时建议 error=5 (duplicate),但返回 error=0 也视为幂等成功。
openlivegame 在收到商户 gameurl 请求后立即调用此接口,
用同一份 token 询问商户:此 token 是否有效、对应哪个玩家、当前余额是多少。
请求参数(form)
| 字段 | 类型 | 说明 |
|---|---|---|
token | string | 商户生成的玩家 token(与 gameurl 的 token 相同) |
providerId | string | 固定值 ppgame |
hash | string | 请求签名 |
响应字段(JSON)
| 字段 | 类型 | 说明 |
|---|---|---|
userId | string | 玩家唯一 ID,必须等于 gameurl 请求中的 externalPlayerId |
currency | string | 玩家账户币种。必须与 gameurl 请求的 currency 一致;同一 userId 的 currency 一经确立不可更改 |
cash | number | 当前现金余额,2 位小数 |
error | int | 错误码,0 表示成功,见 错误码表 |
description | string | 错误描述 |
请求样例
POST /wallet/authenticate HTTP/1.1
Host: merchant-wallet.example.com
Content-Type: application/x-www-form-urlencoded
token=player-token-xyz&providerId=ppgame&hash=60dacf2fedfea2114586cea38f5685e0
# 示例 hash 以 secretKey=mysecretkey 计算
成功响应
{
"userId": "player-001",
"currency": "USD",
"cash": 1000.50,
"error": 0,
"description": "OK"
}
失败响应
{
"userId": "",
"currency": "",
"cash": 0,
"error": 3,
"description": "invalid token"
}
会话恢复、余额刷新等场景下 openlivegame 会调用此接口拉取商户侧玩家的最新现金余额。
请求参数(form)
| 字段 | 类型 | 说明 |
|---|---|---|
userId | string | 玩家 ID(即 authenticate 返回的 userId) |
providerId | string | 固定值 ppgame |
hash | string | 请求签名 |
响应字段(JSON)
| 字段 | 类型 | 说明 |
|---|---|---|
currency | string | 币种 |
cash | number | 当前余额 |
error | int | 错误码 |
description | string | 错误描述 |
成功响应
{
"currency": "USD",
"cash": 1050.75,
"error": 0,
"description": "OK"
}
玩家在游戏中确认下注时调用,商户需从玩家余额中 扣减 amount。
请求参数(form)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
userId | string | 必填 | 玩家 ID(= externalPlayerId) |
tableCode | string | 必填 | 机台编号(= getcasinogames 的 gameId / gameurl 的 gameId 入参)。注意字段名是 tableCode,不是 tableId |
gameId | string | 必填 | 供应商局号(raw,同机台内唯一;跨机台可重号,唯一定位一局请用 (tableCode, gameId) 组合) |
roundId | string | 必填 | 业务局号(一玩家一局唯一,16 位纯数字);由 (tableCode, gameId, userId) 派生,同三元组恒返同值 |
amount | string(decimal) | 必填 | 下注金额,2 位小数字符串,如 "10.50" |
reference | string | 必填 | 幂等键 = "B" + roundId(17 字符);bet/result/refund 分别用 B/R/F 前缀隔离 |
providerId | string | 必填 | 固定值 ppgame |
timestamp | string(int64) | 必填 | 请求时间戳,毫秒 |
hash | string | 必填 | 请求签名 |
roundDetails | string(json) | 预留 | 局级附加信息(JSON 字符串)。当前版本不会发送,商户实现不必处理;如出现请按可选字段兼容(仍参与签名) |
响应字段(JSON)
| 字段 | 类型 | 说明 |
|---|---|---|
transactionId | string | 商户侧交易流水号 |
currency | string | 币种 |
cash | number | 扣款后的玩家余额 |
error | int | 错误码,余额不足时返回 1 |
description | string | 错误描述 |
成功响应
{
"transactionId": "txn-20260423-001",
"currency": "USD",
"cash": 989.50,
"error": 0,
"description": "OK"
}
余额不足响应
{
"transactionId": "",
"currency": "USD",
"cash": 5.00,
"error": 1,
"description": "insufficient balance"
}
cash 返回 当前真实余额,error=1。
openlivegame 将据此拒绝该笔下注并通知客户端。
/bet 的调用超时是 5 秒(下注在牌局倒计时内完成,无法长等)。
若商户响应超时、或返回 error=100 / 未知错误码,openlivegame 视本笔扣款为
状态不确定:会将该笔写入退款确认队列,由后台任务发起对应的
/refund 对冲(reference = "F"+roundId)。
因此商户 /bet 必须保证「要么落账要么干净失败」,且 /refund 幂等必须健壮。
游戏结算后对每一个有成功下注的玩家调用,商户需将 amount 加到玩家余额上。
未中奖的局也会调用,此时 amount="0.00"——商户应照常落一条加款 0 的流水并返回成功,
以便双方对账确认该局已结算。
请求参数(form)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
userId | string | 必填 | 玩家 ID |
tableCode | string | 必填 | 机台编号(与 bet 同) |
gameId | string | 必填 | 供应商局号(与 bet 同) |
roundId | string | 必填 | 业务局号(与 bet 同) |
amount | string(decimal) | 必填 | 派奖金额(≥ 0),2 位小数;未中奖为 "0.00" |
reference | string | 必填 | 派奖幂等键 = "R" + roundId(17 字符);与 bet 的 "B"+roundId 不同,保证端点隔离 |
providerId | string | 必填 | 固定值 ppgame |
timestamp | string(int64) | 必填 | 毫秒时间戳 |
hash | string | 必填 | 请求签名 |
响应字段(JSON)
同 bet:transactionId / currency / cash / error / description。
成功响应
{
"transactionId": "result-20260423-001",
"currency": "USD",
"cash": 1014.50,
"error": 0,
"description": "OK"
}
R+roundId 的幂等。
/result 前强制校验本局存在对应的成功 /bet 扣款流水,
不存在则拒绝结算并转人工。商户不会收到「没有 bet 的 result」(如收到,按下文 refund 的建议返回 error=2)。
两种场景触发:① 整局被作废(游戏中断 / 上游取消本局);②
/bet 调用超时或返回不确定错误(error=100 / 未知码),平台无法确认扣款是否生效,
按「可能已扣款」入队退款。openlivegame 调用 refund 请求商户把 bet 扣掉的金额 退还给玩家。
请求参数(form)
字段与 result 完全相同:userId / tableCode / gameId / roundId /
amount(退款金额,正数) / reference(退款幂等键 = "F" + roundId,17 字符,与 bet/result 端点隔离) /
providerId / timestamp / hash。
响应字段(JSON)
同 bet:transactionId / currency / cash / error / description。
成功响应
{
"transactionId": "refund-20260423-001",
"currency": "USD",
"cash": 1000.00,
"error": 0,
"description": "OK"
}
error=0 与 error=5(duplicate)都视为退款成功。
注意「bet 超时」场景:若商户实际并未扣款(bet 没有落账),收到对应 refund 时应返回
error=2 或携带原余额的失败响应,而不是凭空加款。
签名算法 #
双向通用(openlivegame验商户签名、商户验openlivegame签名)。签名字段名为 hash。
步骤
- 收集所有请求参数的 原始值(表单解码后),排除
hash本身。 - 对参数名按 字母序(ASCII 升序)排序。
- 按顺序拼接为
k1=v1&k2=v2&...&kn=vn。 - 在末尾 直接追加
secretKey(无分隔符)。 - 对整串做
MD5,取 小写十六进制。
Go 参考实现
func CalcSign(params map[string]string, secretKey string) string {
keys := make([]string, 0, len(params))
for k := range params {
if k == "hash" { continue }
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for i, k := range keys {
if i > 0 { b.WriteByte('&') }
b.WriteString(k)
b.WriteByte('=')
b.WriteString(params[k])
}
b.WriteString(secretKey)
sum := md5.Sum([]byte(b.String()))
return hex.EncodeToString(sum[:])
}
PHP 参考实现
function calcSign(array $params, string $secretKey): string {
unset($params['hash']);
ksort($params);
$parts = [];
foreach ($params as $k => $v) {
$parts[] = $k . '=' . $v;
}
return md5(implode('&', $parts) . $secretKey);
}
Python 参考实现
import hashlib
def calc_sign(params: dict, secret_key: str) -> str:
items = sorted((k, v) for k, v in params.items() if k != "hash")
payload = "&".join(f"{k}={v}" for k, v in items) + secret_key
return hashlib.md5(payload.encode()).hexdigest()
Node.js 参考实现
const crypto = require('crypto');
function calcSign(params, secretKey) {
const payload = Object.keys(params)
.filter(k => k !== 'hash')
.sort()
.map(k => `${k}=${params[k]}`)
.join('&') + secretKey;
return crypto.createHash('md5').update(payload).digest('hex');
}
完整示例
假设:
secureLogin | merchant001 |
token | player-token-123 |
externalPlayerId | player-001 |
currency | USD |
gameId | 545 |
secretKey | mysecretkey |
排序后拼接:
currency=USD&externalPlayerId=player-001&gameId=545&secureLogin=merchant001&token=player-token-123
追加 secretKey:
currency=USD&externalPlayerId=player-001&gameId=545&secureLogin=merchant001&token=player-token-123mysecretkey
MD5 结果即为 hash:
ce14a7aa5f8c5c23d92d088e94f76f19
你的签名实现对上述输入应输出完全相同的结果(注意本例未含 language, 实际调用 gameurl 时按上文 签名参与规则 把 language 计入)。
- 参数值若含
&/=/ 空格等字符,签名时使用 解码后的原始值,HTTP 传输时才 URL 编码。 - 商户验证openlivegame回调时:对收到的全部非 hash 字段计算(openlivegame发送什么就签什么,包括
timestamp)。 - 商户调用 Provider API 时:可选字段遵循各端点的「签名参与规则」,空值字段不发送、不计入。
- secretKey 只在签名末尾出现,不 通过 HTTP 传输。
错误码 #
Provider API 错误码(字符串类型)
用于openlivegame返回给商户的响应中的 error 字段。
| error | 说明 | 触发场景 |
|---|---|---|
| "0" | 成功 | — |
| "1" | 内部错误 | 服务异常、数据库错误等 |
| "2" | 无效 secureLogin | 商户未注册或 secureLogin 错误 |
| "3" | 商户已禁用 | 商户或其所在组织被禁用 |
| "4" | 资源不存在 | authenticate 业务失败、userId 与 externalPlayerId 不匹配、roundreport 查无此局 |
| "5" | 签名错误 | hash 校验失败 |
| "7" | 参数格式错误 | externalPlayerId 不符合正则、roundId 格式非法 |
| "14" | 缺少必填字段 | 必填参数未传或绑定失败 |
| "20" | 币种不匹配 | externalPlayerId 已绑定其他币种、或 authenticate 返回的 currency 与请求不一致 |
Seamless Wallet 错误码(整型)
用于商户返回给openlivegame的响应中的 error 字段。
| error | 说明 | openlivegame处理逻辑 |
|---|---|---|
| 0 | 成功 | 继续业务流程 |
| 1 | 余额不足 | 拒绝下注,提示玩家 |
| 2 | 用户不存在 | 会话终止 |
| 3 | 无效 token | gameurl 返回 error=4,拒绝进入游戏 |
| 4 | 签名错误 | 错误日志,检查 secretKey |
| 5 | 重复交易 | 视为幂等成功,以此次响应为准 |
| 100 | 内部错误 | 状态不确定:bet 触发 refund 对冲;result/refund 进入重试队列 |
幂等、重试与时序 #
幂等键设计建议
对 bet / result / refund,商户应把
(operatorId, reference) 作为联合唯一键。
收到请求时先查此唯一键,若已存在则直接返回该流水的 transactionId + cash_after,
避免重复扣加款。
超时设定
openlivegame 调用商户的 client timeout:/bet 5 秒,其余接口 10 秒。
商户回调接口应保证 P99 响应时间 < 3s;bet 超时会被视为状态不确定并触发 refund 对冲,
带来资金状态不一致风险。
重试策略
result 网络失败先即时重试(至多 3 次尝试);仍失败与 refund 任务一起进入
b_wallet_callback_tasks 重试队列(每 15 秒扫描,result 至多 5 次 / refund 至多 3 次),
超限转人工介入。因此商户必须对同一 reference 做严格幂等。
时序关系
同一 roundId 的正常时序为 bet → result(含 amount=0 的未中奖结算);
异常时序为 bet → refund(整局作废或 bet 状态不确定)。
正常情况下同一局不会同时出现 result 与 refund。
amount 为字符串(2 位小数)。商户实现必须使用 Decimal / BigDecimal
等精确类型计算,严禁用 float 做中间计算,以避免余额累积误差。
商户幂等实现(必读) #
openlivegame 的重试机制保证了失败请求一定会重试,商户若不做正确的幂等处理,会出现重复扣款 / 重复加款 / 资金对不平的严重事故。 本章给出强制性的实现要求。
1. 幂等键与唯一键
适用接口
/bet、/result、/refund 三个接口必须强制幂等。/authenticate 与 /balance 为只读接口,不写入状态,天然幂等。
幂等键
请求中的 reference 字段即幂等键(1 位类型前缀 B/R/F + 16 位 roundId,共 17 字符),由 openlivegame 生成,在同一商户范围内全局唯一。
数据库唯一键
商户交易流水表(建议命名 wallet_transactions)必须建立以下数据库级唯一索引:
CREATE UNIQUE INDEX uk_operator_reference
ON wallet_transactions (operator_id, reference);
-- 同时建议加一个业务维度索引用于查询和对账
CREATE INDEX ix_user_round_type
ON wallet_transactions (user_id, round_id, txn_type);
说明:reference 在 openlivegame 侧对单个商户全局唯一,因此唯一键用 (operator_id, reference)
足以防止所有重复提交。依靠代码层 if exists then skip 而没有 DB 唯一索引的实现,
在并发场景下会失效。
2. 三种重复请求场景的正确响应
商户收到带有已见过的 reference 的请求时,必须按实际状态返回。不要重复扣 / 加款。
| 场景 | 识别条件 | 推荐响应 |
|---|---|---|
| A 已成功 | DB 中存在相同 reference 且状态为 success |
返回当时那笔流水的 transactionId 和成功时的余额 cash。error=0(推荐)或 error=5(duplicate,语义更准确),两者 openlivegame 都视为幂等成功。
|
| B 已失败 | DB 中存在相同 reference 且状态为 failed(如余额不足) |
返回与当时一致的失败响应(例如 error=1 + 当前余额)。不要重新执行业务逻辑,避免"之前余额不足、现在余额够了"导致迟到下注生效。
|
| C 并发中 | 两个线程几乎同时收到同一 reference 请求 | 依赖 DB 唯一索引兜底:一条成功插入,另一条插入时命中唯一冲突,按场景 A 回查并返回已有流水。 |
3. 标准实现伪代码
Bet 接口的推荐流程
// 关键不变量:唯一键 (operator_id, reference) 限制下,一个 reference 只能有一条记录。
BEGIN TRANSACTION;
// Step 1: 先查幂等键,命中则直接返回该流水(场景 A / B)
tx := SELECT * FROM wallet_transactions
WHERE operator_id = $op AND reference = $ref
FOR UPDATE; // 行级锁保证并发安全
if tx exists {
return { transactionId: tx.id, cash: tx.cash_after,
currency: tx.currency, error: tx.error_code,
description: tx.description };
}
// Step 2: 锁定玩家账户行
player := SELECT * FROM players
WHERE operator_id = $op AND user_id = $userId
FOR UPDATE;
// Step 3: 业务判断
if player.cash < $amount {
// 余额不足也要落一条失败流水,重试命中时场景 B 返回
INSERT INTO wallet_transactions(operator_id, reference, user_id,
table_code, game_id, round_id, txn_type, amount, cash_before, cash_after,
error_code, description, status)
VALUES ($op, $ref, $userId,
$tableCode, $gameId, $roundId, 'bet',
$amount, player.cash, player.cash,
1, 'insufficient balance', 'failed');
COMMIT;
return { cash: player.cash, error: 1,
description: "insufficient balance" };
}
// Step 4: 扣减余额 + 写流水(同一事务内)
UPDATE players SET cash = cash - $amount
WHERE id = player.id;
txId := INSERT INTO wallet_transactions(operator_id, reference, user_id,
table_code, game_id, round_id, txn_type, amount, cash_before, cash_after,
error_code, description, status)
VALUES ($op, $ref, $userId,
$tableCode, $gameId, $roundId, 'bet',
$amount, player.cash, player.cash - $amount,
0, 'OK', 'success')
ON CONFLICT (operator_id, reference) DO NOTHING
RETURNING id;
// 并发冲突兜底:另一个线程已插入 → 回滚本次扣款,查询已有流水返回(场景 C)
if txId is null {
ROLLBACK;
goto Step 1;
}
COMMIT;
return { transactionId: txId, cash: player.cash - $amount,
currency: player.currency, error: 0, description: "OK" };
Result 与 Refund 同构,区别仅是 txn_type 以及余额是加而非减,且无余额不足分支。
注意 result 的 amount 可能为 0(未中奖结算)——照常落流水、加 0、返回成功。
4. Refund 的特殊要求
B/R/F 前缀隔离("B" + roundId / "R" + roundId / "F" + roundId),
所以 refund 的 reference 与 bet/result 的 reference 不同。商户判定某次 refund 是否合法时,不能直接用
refund 的 reference 去比对 bet 表。应基于 (tableCode, gameId, userId) 三件套 + txn_type='bet' 定位原始 bet
(等价地,也可以基于 roundId + txn_type='bet',因为 roundId 是三件套的派生),校验后再按 refund 的 reference 做幂等。
- Refund 的
amount总是正数,表示加款金额 - 商户收到 Refund 时建议:定位 bet 流水 → 校验
amount <= bet.amount→ 写 refund 流水并加款 → 可选地把 bet 标记为 refunded - Refund 对应的 bet 可能在商户侧不存在(bet 超时、商户实际未落账的场景)。此时不要凭空加款,
应返回
error=2,openlivegame 侧按任务失败转人工核对
5. 常见反模式(不要这么写)
| 反模式 | 后果 | 正确做法 |
|---|---|---|
只在应用层 if exists,不建 DB 唯一索引 |
并发下两个请求都先查到不存在,双方都插入,扣两次款 | DB 唯一索引 + ON CONFLICT DO NOTHING 兜底 |
| 重复请求时"重新跑一遍业务" | 第一次余额不足失败,重试时因用户充值已成功,导致迟到下注生效 | 重复请求必须严格返回首次结果,包括首次的错误 |
| 扣款和写流水不在同一事务 | 扣款成功但流水写入失败 → 幂等失效 → 下次重试再扣一次 | 扣款、写流水必须在同一个 DB 事务内提交 |
| 用 float 做余额计算 | 10.10 - 10.00 ≠ 0.10,长期累计后余额对不平 | Decimal / BigDecimal / numeric(18,2) 等精确类型 |
| 失败请求返回 HTTP 5xx | openlivegame 无法区分业务失败与网络失败,全部进入重试队列,加剧雪崩 | 业务错误 一律 HTTP 200,错误码放 error 字段 |
| Authenticate 返回的 userId 与 externalPlayerId 不一致 | gameurl 直接返回 error=4,玩家进不了游戏 | 严格使用同一份玩家标识,在 token 表和玩家表中保持映射一致 |
同一 externalPlayerId 在不同场次传入不同 currency |
gameurl 返回 error=20,玩家进不了游戏 |
一个 externalPlayerId 终身绑定一种币种。多币种用户用
{uid}_{ccy} 这类派生 ID,由商户维护自然人与多 ID 的映射
|
把回调里的 gameId 当全局唯一局号入唯一索引 |
gameId 跨机台可重号,撞唯一键后丢单 | 跨机台唯一请用 (tableCode, gameId) 组合,或直接用 roundId |
6. 每日对账
强烈建议商户实现与 openlivegame 的 T+1 对账机制(通过 openlivegame 运维后台的报表或导出功能)。
对账维度:(operator_id, date, txn_type) 下的总金额与总笔数。
任何差异都应在当日发现、当日修复,避免问题滚雪球。
上线前自查清单 #
商户侧实现核对
- ☐ 已与 openlivegame 协商并记录
secureLogin/secretKey/callback_url三项配置 - ☐ 实现了 5 个回调端点:
/authenticate//balance//bet//result//refund - ☐ 所有回调对
hash字段做了签名校验(对全部非 hash 字段计算),签名失败返回error=4 - ☐ 签名实现用本文档「完整示例」的输入跑出了相同的 MD5 结果
- ☐ 生成的 token 是一次性的、短期有效、与玩家绑定
- ☐ authenticate 返回的
userId一定等于商户传给 gameurl 的externalPlayerId,currency一定等于 gameurl 请求的 currency - ☐ 多币种用户已使用不同的
externalPlayerId(如uid_USD/uid_EUR),并在内部维护了自然人到多 ID 的映射 - ☐ 解析回调时机台字段读的是
tableCode(不是 tableId) - ☐ bet / result / refund 基于
reference实现了数据库唯一索引级幂等(UNIQUE (operator_id, reference)) - ☐ result 在
amount="0.00"(未中奖结算)时也能正确落流水并返回成功 - ☐ 扣款 / 加款与流水写入在同一 DB 事务内提交
- ☐ 重复请求不重跑业务,严格返回首次落库的流水与错误码
- ☐ 失败场景(余额不足等)也会落一条
status=failed流水,保证重试幂等 - ☐ 找不到对应 bet 的 refund 返回
error=2,不凭空加款 - ☐ 所有业务错误均使用 HTTP 200 + JSON
error字段返回,未用 HTTP 4xx/5xx 代替业务错误 - ☐ 余额计算使用 Decimal 类型,未使用 float
- ☐ 回调接口 P99 响应 < 3 秒(bet 的硬超时只有 5 秒)
- ☐ 玩家账户与资金流水表设计了幂等隔离,不会因并发下注导致透支
- ☐ 生产环境配好了服务器 IP 白名单(如有)并向 openlivegame 报备
- ☐ 已完成 openlivegame 提供的联调沙箱测试,包含成功、余额不足、重复 reference、退款等场景
建议的监控指标
- 回调接口 QPS / P99 延迟 / 错误率(按 endpoint 拆分)
error=1(余额不足)的日占比趋势error=5(重复交易)次数 —— 少量正常(对应openlivegame重试),激增需联系openlivegame排查- 未匹配到 bet 却请求 refund 的次数 —— 对应「bet 超时未落账」场景,激增说明商户侧 bet 链路过慢
- 签名校验失败次数 —— 可能是密钥泄露或被攻击