# PlayRouter Transfer Wallet · 运营商对接文档（v1）

> **头注**：本文件是 `playrouter-transfer-docs.html` 的 Markdown 版，生成日期 2026-07-06，供 LLM / AI 编码助手阅读；可交互 Playground 与图示请看 HTML 文档站；字段级权威为 OpenAPI yaml（`playrouter-transfer-wallet-v1.openapi.yaml`）。

PlayRouter Transfer Wallet（转账钱包）· 11 端点 · 零回调 · 2026-07-06 · Seamless Wallet 产品见对应文档站（playrouter-seamless-docs.html）。

> **四句话读懂本产品**
> **① 调 7 个端点、实现 0 个回调就能上线**（游戏期间我们不会调用你的任何接口）。**② 玩家玩哪家厂商，钱就划到哪家**——PlayRouter 只转发划拨指令，不持有玩家资金。**③ 输赢经 `records/list` 拉取**——这是你知道玩家输赢的唯一渠道。**④ 拿不到响应就同 `idempotency_key` 重发，恒安全（在同一区内）**——没有任何"重发前先查单"的仪式。

## 接入之旅：全貌一张图

整套对接只有**一个方向**：全部端点都是你调用归属服 `{eu|sea|latam}.api.playrouter.io`——划拨、启动、目录、对账。**零回调**：游戏期间我们不会调用你的任何接口，你不需要暴露任何服务。端点按「洋葱」分层：**接通必须层就可以上线**——7 个端点 + 0 个回调；查单两件推荐接（异常处理更从容），terminate 按需。下面每个端点的标题上都标了必须 / 可选。

```text
唯一方向：你 ──调用──▶ {eu|sea|latam}.api.playrouter.io   （11 个端点：划拨 / 启动 / 目录 / 对账）
没有方向 B —— 游戏期间 PlayRouter 不会调用你（对比 Seamless 的 5 个回调）
```

（原文此处为「接入分层洋葱图」SVG，含义如下：三层同心结构，从内到外按需剥开，外圈永远不构成上线门槛。最内圈是「必须 · 上线门槛 = 7 + 0」：sessions/create · games/list · providers/list · currencies · transfers/create · wallet/balance · records/list，＋ 0 个回调——没有要实现的服务端；中间圈是「查单（可选 · 推荐——pending 轮询与人工排障）」：transfers/get · records/get · wallet/balances（厂商内余额归集）；最外圈是「会话辅助（可选）」：sessions/terminate（best-effort）。）

| 层 | 端点 | 什么时候需要 |
|---|---|---|
| **必须 · 上线门槛** | sessions/create · games/list · providers/list · currencies<br>transfers/create · wallet/balance · records/list | 第一天。认证套件只考这 7 个（+ 0 个回调） |
| **查单** | transfers/get · records/get · wallet/balances | pending 划拨轮询终态、客服按单号排障、厂商内多产品/场馆余额归集时（推荐，可后补） |
| **会话辅助** | sessions/terminate | 风控/自我排除要把人请出游戏时（best-effort） |

## 钱包模式与拓扑

转账钱包模式下，玩家在你系统里的余额和在厂商侧游戏钱包里的余额**各自独立**。玩家玩游戏前你先"上分"（把钱划进厂商侧游戏钱包），玩完"下分"取回。PlayRouter 是中间的统一转发层：一套 API、一套签名、一套幂等，替你对接所有转账形态的厂商。

（原文此处为「图 1 · 纯转发拓扑」SVG，含义如下：左侧是你（运营商，持玩家主钱包——上分后那部分钱就不在这了），中间是 PlayRouter（纯转发 · 不持资金），右侧是逐厂商一个的游戏钱包（如 PP 游戏钱包 / JDB 游戏钱包 / SABA 游戏钱包——游戏期间，钱停在这里）。上分 `transfers/create(deposit)` 把钱从主钱包经 PlayRouter 划入指定厂商的游戏钱包，下分 `transfers/create(withdraw)` 划回；PlayRouter 不是托管方——划拨、查余额都要指定 provider（provider 必填）；输赢只能事后拉 `records/list`（输赢明细的唯一渠道）。）

### 三条你要提前知道的规则

| 规则 | 含义 |
|---|---|
| **逐厂商钱包** | 玩家在每家厂商各有一个游戏钱包。跨厂商换游戏 = 先从 A 家 `withdraw`、再向 B 家 `deposit`（两笔独立划拨）。 |
| **单币种钱包** | 每个 `player_id` 首次划拨/启动时确定币种，此后不可改。多币种玩家请拆多个 player_id（如 `wang-123-brl` / `wang-123-usd`）。 |
| **目录边界** | `games/list` 只含转账形态厂商。仅有 seamless 形态的厂商在本产品不可见——没有任何一方持有它们要调用的钱包。 |

> **与 Seamless Wallet 产品的关系**
> 本文档自包含，你**不需要**看 seamless 产品的任何文档。目录/会话/币种 5 个端点（sessions/create·terminate、games/list、providers/list、currencies）与 seamless 产品字段逐字相同，只是路径前缀不同（本面 `/v1/transfer/…`，seamless 面 `/v1/seamless/…`）——将来若升级 seamless，这 5 个端点切换只改前缀，**密钥也是同一把**（密钥账号级通用，2026-07-20 起）：一把 `pk_/sk_` 对两条产品线同时有效，升级 seamless 无需换钥。你能调用哪些端点取决于账号的产品开通状态，与密钥无关——体现在路径前缀上：未开通的面返回 403 `product_not_enabled`。

## 五分钟快速开始

1. **拿沙箱密钥**：控制台注册 → 创建密钥 → 得 `pk_test_…` / `sk_test_…`（账号级，两条产品线通用）。沙箱与生产完全隔离。
2. **调 `ping` 级验证：providers/list + currencies**：两个零参数端点（body 传 `{}`），验证签名代码是否正确，同时拿到厂商目录与币种精度表。
3. **同步游戏目录**：`games/list` 首次用响应头 `X-Full-Dump-Url` 的每日快照，日常用 `updated_after` 增量。
4. **上分 → 启动 → 下分**：`transfers/create(deposit)` 划 100 进厂商钱包 → `sessions/create` 拿启动 URL → 玩家玩完 `wallet/balance` 看 transferable → `transfers/create(withdraw)` 取回。
5. **接对账流**：定时拉 `records/list`（这是你知道输赢的唯一渠道），按 `record_id` 去重入库。到这里，7 个必须端点全部接完，可申请认证。

### 第一个请求（复制即跑）

```javascript
// Node 18+ · 对 providers/list 发一个已签名请求
import crypto from "node:crypto";

const KEY_ID = "pk_test_xxx", SECRET = "sk_test_xxx";
const body = "{}";                              // raw body，序列化后不得再改
const t = Math.floor(Date.now() / 1000);
const v1 = crypto.createHmac("sha256", SECRET)
  .update(`${t}.${body}`).digest("hex");

const res = await fetch("https://api.sandbox.playrouter.io/v1/transfer/providers/list", {
  method: "POST",
  headers: {
    "Content-Type": "application/json; charset=utf-8",
    "X-Playrouter-Key-Id": KEY_ID,
    "X-Playrouter-Signature": `t=${t},v1=${v1}`
  },
  body
});
console.log(await res.json());   // → { data: [{ code:"pg", name:"PG Soft", ... }] }
```

## 三个核心概念（先建立心智模型）

整套 API 只依赖三个概念。用你熟悉的电商类比一次讲清，后面所有字段表都会变得好读。

### transfer ＝ 转账凭证（划拨单）

每次上分/下分是一张划拨单，`transfer_id`（`trf_`）是它的全局唯一编号。三态终局：`completed` / `failed`（带 reason）/ `pending`（轮询 `transfers/get` 等终态）。重发靠 `idempotency_key`（推荐 `tf-{内部订单ID}`）——拿不到响应同 key 重发恒安全（在同一区内）。

### record ＝ 输赢流水（注单）

玩家在厂商侧玩的每一局，事后以一条记录出现在 `records/list` 里，`record_id`（`rec_`）是它的编号，也是你入库的**去重键**——转账产品没有 transaction 回调，rec_ 就是你这侧的"流水号"。这是你知道输赢的唯一渠道，按分钟级拉取。

### 逐厂商钱包 ＋ 全盲期

玩家在每家厂商各有一个游戏钱包（划拨和查余额都要指定 `provider`）。钱划过去之后、拉到记录之前，**你是全盲的**——游戏期间零回调，余额问 `wallet/balance`、输赢等 `records/list`。这是转账形态的本质代价，换来的是零服务端成本。

### 附赠：transferable ＝ 可取余额预览

`wallet/balance` 响应里的可划回金额（局内锁定部分不含）。支持的厂商给这个字段，下分前先看它，一次取准；不支持的厂商按 `balance` 试、按 reason 重试（见「上分 / 下分流程」的取回全部循环）。

## 全景时序：一次上分到下分的一生

四个角色：**玩家**在你的站点玩、游戏由**厂商**提供、**PlayRouter** 居中转发、**你**只发起调用。下图一次讲完上分、全盲游玩、下分与对账——注意中段：那里**没有任何指向你的箭头**，这就是零回调。

（原文此处为「全景时序图」SVG，四条泳道：玩家 / 游戏厂商 / PlayRouter / 你（运营商），分三段：

**① 上分 ＋ 启动**：玩家在你的站点点「上分 100 并进入游戏」→ 你先扣主钱包 → 调 `transfers/create(deposit)` → PlayRouter 划入厂商侧游戏钱包 → 响应 `200 · status "completed" · trf_01H…` → 你调 `sessions/create`（拿游戏启动 URL）→ 游戏 URL 经你返回，玩家进入厂商游戏。

**② 游玩（全盲期——没有任何指向你的箭头）**：玩家 spin · spin · spin …，输赢直扣 / 直入厂商侧游戏钱包；你这侧：静默。想看余额可随时 `wallet/balance`。

**③ 下分 ＋ 对账（还是你发起）**：你调 `wallet/balance`（先问能取多少）→ 响应 `transferable "124.50"` → 你调 `transfers/create(withdraw "124.50")` → 响应 `200 · "completed"`——此刻才给玩家主钱包入账 → 你定时拉 `records/list` → 输赢明细（rec_ 去重入库）——你知道输赢的唯一渠道。）

> **顺序铁律：上分先扣自己、后划厂商；下分先划回来、确认成功再入账**（详见「上分 / 下分流程」）。

## 签名（必读 · 你唯一的安全动作）

本产品只有一个方向要签：**你给每个请求签名、我们验**（失败 = `401`，语义正确且唯一）。零回调意味着**没有验签义务、没有入站白名单、不需要维护 token**——把请求签对，安全面就完整了。算法与 PlayRouter 全线产品逐字相同：HMAC-SHA256 对「时间戳 + 原始请求体」签名，装在两个固定请求头里。

```text
X-Playrouter-Key-Id:    pk_live_01H8XGJWBW          ← 用哪把钥匙（支持两把并行，轮换用）
X-Playrouter-Signature: t=1719792000,v1=5257a869…   ← t 是 Unix 秒，v1 是签名

# 签名三步：
# 1. body 序列化成最终字符串——此后不得再格式化，签名对象是原始字节
# 2. 拼接 signed_payload = t + "." + raw_body
# 3. v1 = HMAC-SHA256(secret, signed_payload) 的 hex 小写；|服务器时间 − t| > 300 秒会被拒（防重放）
```

### 给你的请求签名（三种语言）

Node.js：

```javascript
const crypto = require('node:crypto');

function signedHeaders(rawBody, keyId, secret) {
  const t  = Math.floor(Date.now() / 1000);
  const v1 = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
  return { 'X-Playrouter-Key-Id': keyId, 'X-Playrouter-Signature': `t=${t},v1=${v1}` };
}
// 先把 body 序列化成最终字符串再签名，然后原样发送——序列化后不得再格式化。
// 签错/时间戳出窗 → 我们返回 401（invalid_signature / invalid_timestamp / unknown_key）。
```

Python：

```python
import hmac, hashlib, time

def signed_headers(raw_body: bytes, key_id: str, secret: str) -> dict:
    t = str(int(time.time()))
    v1 = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return { "X-Playrouter-Key-Id": key_id,
             "X-Playrouter-Signature": f"t={t},v1={v1}" }
```

PHP：

```php
function signedHeaders(string $rawBody, string $keyId, string $secret): array {
    $t  = time();
    $v1 = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    return [ 'X-Playrouter-Key-Id' => $keyId,
             'X-Playrouter-Signature' => "t=$t,v1=$v1" ];
}
```

> **签错会发生什么**
> 答复**有且只有 401**，错误体的 `code` 告诉你错在哪一步：`invalid_signature`（v1 不匹配，九成是 body 被二次格式化）· `invalid_timestamp`（t 出 ±300 秒窗，查服务器时钟）· `unknown_key`（Key-Id 打错或环境不对）。这是配置故障：修好再发，**不要重试轰炸**；报障带上响应头 `X-Request-Id`。

> **防重放与换钥**
> 签名带 timestamp 防重放；Key-Id 支持双钥轮换——每个环境最多 2 把 active key 并行，换钥零停机。

## 幂等与重试

全产品唯一要背的重试规则：**拿不到响应（超时/断连/5xx），同一个 `idempotency_key` 原样重发，恒安全（在同一区内）**。命中重放会返回原响应 + 响应头 `Idempotency-Replayed: true`；同 key 不同 body 会被 409 拒绝（防复制粘贴事故）；键保留 7 天。

**推荐派生方式**

1. 内部划拨订单先落库（write-ahead），拿到订单号
2. 幂等键 = **`tf-{内部订单ID}`**（确定性派生，天然防重）
3. **严禁**每次重试重新生成随机键——那等于没有幂等

**你不需要做的事**

1. 不需要背"哪些错误态可以重发"的清单
2. 不需要重发前先调查单接口"确认状态"
3. 不需要在单号里内嵌时间戳、凑格式、赶 5 分钟窗口

## 上分 / 下分流程

> **铁律一句话：上分先扣自己、后划厂商（失败退回）；下分先划回来、确认成功再入账。顺序错了就是双花或丢分。**

**上分（deposit）**

1. 创建内部订单（状态"新建"）
2. **先扣你侧玩家主钱包**——扣不动就停
3. 调 `transfers/create(type:"deposit")`
4. `completed` → 订单完结；`failed` → **退回主钱包扣款**；`pending` → 进异常队列，`transfers/get` 轮询终态

**下分（withdraw）**

1. 创建内部订单；先查 `wallet/balance` 拿 `transferable`（有此字段的厂商）
2. 调 `transfers/create(type:"withdraw")`，金额 ≤ transferable
3. `completed` → **此刻才给玩家主钱包入账**；`failed` → 按 reason 分支（枚举见「错误与 reason」）；`pending` → 轮询，终态前不入账

### 「取回全部」参考流程（我们有意不做 withdraw_all）

> **为什么没有一键取光**
> "取回全部"只有在钱包静止时才有确定含义，而没有任何厂商提供跨局静止保证（玩家关闭游戏窗口 ≠ 本局已结束）。假装能原子取光是我们不愿意卖的假能力。下面的循环就是诚实答案——你写一次，对所有厂商通用：

```python
# 伪码：取回玩家在某厂商的全部余额
def recall_all(player, provider, recall_id):    # recall_id：本次取回先落库的内部单号，每次取回新生成
    for attempt in range(3):
        bal = wallet_balance(player, provider)
        amount = bal.transferable or bal.balance   # 有预览用预览，没有就按余额试
        if Decimal(amount) == 0: return DONE           # 按数值比较：零写作 "0.00"，别按字符串比
        r = transfers_create(player, provider, "withdraw", amount, key=f"recall-{recall_id}-{attempt}")
        if r.status == "completed": return DONE      # 大多数一轮就结束（此刻再给玩家主钱包入账）
        if r.status == "pending":
            return WAIT(r.transfer_id)                 # 处理中（极少）：先不入账，交给异常订单队列轮询到终态
        if r.reason in ("game_limit", "player_in_game"):
            sleep(60); continue                        # 局内锁：等局结束再来
        if r.reason == "insufficient_funds": continue # 余额刚变了：重查再取
        if r.reason in ("provider_unavailable", "not_executed"):
            sleep(30); continue                        # 资金未动：下一轮换新 key 再取
        return ESCALATE(r)                             # 其余 reason 进人工
    return ESCALATE(r)                                 # 三轮用尽仍没取完：进人工
```

**`recall_id` 必须每次取回新生成并先落库**（同上文「推荐派生方式」）：键保留 7 天，`recall-{player}-{attempt}` 这类下次还会再算出来的键，会让下一次取回撞上上一次的键——金额相同就直接重放上次的结果（上次是 completed，你就会再入账一次），金额不同则被 409 拒绝。

> **`WAIT`（处理中）怎么收尾**：这笔下分还没有结论——先**不要**给玩家主钱包入账，也不必转人工。把它放进你的异常订单队列，用 `transfers/get` 按 ≥30 秒间隔轮询（1 小时后可降频）；终态一定会给出（超过 1 小时我们会人工核对后落终态，无需你处理）。终态出来以后：
> - `completed` → 此刻给玩家主钱包入账；需要的话再调一次 `recall_all`（**新的** `recall_id`）取剩余。
> - `failed` 且 reason 是 `not_executed` 或 `provider_unavailable` → 资金没动、不入账；需要的话用新的 `recall_id` 再调一次 `recall_all`。
> - `failed` 且 reason 是 `insufficient_funds`、`game_limit` 或 `player_in_game` → 不入账；稍后用新的 `recall_id` 再调一次 `recall_all`（局内锁要等局结束）。
> - 其余 reason → 记录并转人工。

循环里出现的各 reason 含义与处置，见「错误与 reason」的枚举表。

## 方向 A · 你调我们（11 端点）

Base URL：`https://{eu|sea|latam}.api.playrouter.io`（沙箱 `https://api.sandbox.playrouter.io`，单区）。改用别的区不需要新账号或新密钥；数据按区存放、区与区不互通——旧区的划拨与注单记录留在旧区，照常可查。**玩家已在某个区上分进游戏的余额，请在同一个区查询和取回**，取回后再到新区上分。**拿不到响应要重试时，也请留在同一个区**：同一个 `idempotency_key` 只在同一区内去重，换到别的区重发会被当成一笔新请求再执行一次。**路径第二段 = 钱包模式面**：本产品全部端点都在 `/v1/transfer/<域>/<动作>` 下（如 `/v1/transfer/transfers/create`、`/v1/transfer/currencies`）；seamless 产品的同名端点在 `/v1/seamless/…` 下。账号开通了哪一面才能调哪个前缀——未开通的面一律返回 403 `product_not_enabled`，与端点是否"公共"无关。两面共有的 5 个目录/会话端点字段逐字相同，切换只改前缀。

### POST `/v1/transfer/sessions/create`（上线必须）

启动游戏，真钱/试玩合一。转账模式注意：启动前先上分——余额 0 也能进游戏，但玩家会看到 0 余额。`game_code` 的厂商段（冒号前）决定厂商，具体钱包由 `provider_product` 决定（多产品线厂商）。

**请求字段**

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| player_id | string | real 必填 | ≤ 64 字符；你侧稳定玩家标识；demo 可省。不收 PII。单币种钱包：一个 player_id 一个币种 | "wang-123" |
| game_code | string | 必填 | `<provider>:<游戏段>`，与 games/list 同一把键。**不透明键，原样回传**：多产品线厂商（目前只有 `db`，DB Gaming）的游戏段形如 `card-101`（产品线-厂商游戏码），单产品线厂商的游戏段就是厂商游戏码原文；不要自行拆游戏段，产品维度用 `provider_product` 字段 | "pg:fortune-tiger" / "db:card-101" |
| currency | string | 必填 | real = 钱包币种（精确匹配永不降级；厂商 / 实例不收该币种 → 422 currency_not_supported，在建户 / 打厂商之前拒，如 saba 只登记 USD 时的 CNY；该币种平台侧暂时不可用时同码、`fields.currency` = `temporarily unavailable`，不要换币种、稍后重试）；demo = 期望展示币种（不支持则静默降级厂商默认，不校验） | "BRL" |
| mode | string | 必填 | real \| demo | "real" |
| ip | string | real 必填 | 玩家 IP，IPv4/IPv6 | "203.0.113.7" |
| locale | string | 可选 | ISO 639-1 小写；不支持静默降级 en | "pt" |
| device | string | 可选 | desktop \| mobile | "mobile" |
| lobby_url | string | 可选 | 退出游戏返回地址 | "https://op.com/lobby" |
| deposit_url | string | 可选 | 游戏内"去充值"跳转；未提供时降级用 lobby_url | "https://op.com/cashier" |
| launch_mode | string | 可选 | url（默认）\| html——HTML 直出（抗封锁）；厂商不支持时声明降级 | "url" |
| session_payload | string | 可选 | ≤1024 字符透传串，出现在 records 关联与排障日志 | "wsess-8f2\|ios" |

**响应字段**

| 字段 | 类型 | 出现 | 说明 | 示例 |
|---|---|---|---|---|
| session_id | string | 总是有 | 会话锚点（ses_），排障/终止用 | "ses_01H8XGJWBW" |
| url | string | 总是有 | 启动 URL 恒在（HTML 型厂商由我们包装成 URL）。**拿到后立即打开、只用一次**：链接有效期由游戏厂商决定，短的只有约 1 分钟，部分厂商的链接只能打开一次；玩家每次进游戏（含刷新页面、重新打开）都重新调用 sessions/create 取新链接，不要缓存复用 | "https://launch..." |
| html | string | 不一定有 | 仅 launch_mode:"html" 且厂商支持时出现；从你的自有域名伺服 | "&lt;!DOCTYPE html&gt;..." |
| expires_at | string | 总是有 | 启动 URL 最晚失效时刻（RFC 3339）；实际有效期由游戏厂商决定、可能早得多（见 url 行），不要据此缓存复用 url | "2026-07-06T12:30:00.000Z" |

> **启动失败怎么处理**：
> - 422 `game_not_available`：游戏当前不对这个玩家开放（下架、地域限制、未到 released_at，或厂商拒绝该玩家进入）——重试同一请求不会成功，提示玩家换一款游戏。**例外**：`fields.game_code` = `temporarily unavailable` 表示平台侧暂时不可用、游戏本身没有下架——稍后重试同一请求即可，你不需要做任何配置，也不要把游戏从大厅撤下。
> - 422 `demo_not_available`：这款游戏不支持试玩。
> - 502 `provider_error`：临时故障，稍后用同一请求重试。

### POST `/v1/transfer/games/list`（上线必须）

游戏目录：对象数组 + cursor 分页 + `updated_after` 增量。首次同步用响应头 `X-Full-Dump-Url` 的每日全量快照；链接下载失败时，改用不带 `updated_after` 的分页全量。**目录只含转账形态厂商**。

**请求字段**

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| cursor | string\|null | 可选 | 黑盒游标：上页 next_cursor 原样传回；首页省略 | null |
| limit | integer | 可选 | 1–2000 | 500 |
| updated_after | string | 可选 | 增量同步：只拉此后变更（厂商下架也是一次变更，status 变 recalled；从目录消失的游戏不在增量里，见响应 status 说明） | "2026-07-01T00:00:00.000Z" |
| provider | string | 可选 | 按厂商过滤（单值），值来自本面 providers/list 的 code | "pg" |
| category | string | 可选 | 按品类过滤（live 为一等品类） | "slots" |
| currency | string | 可选 | 按支持币种过滤 | "BRL" |

> **带过滤条件做增量**：带过滤条件做增量时，只返回当前仍匹配的游戏；游戏因品类或币种登记变化不再匹配时，不会出现在结果里。要跟踪这类变化，增量同步请不带过滤条件，按返回的 `category` / `currencies` 字段在本地筛（这些字段变化时 `updated_at` 会刷新）。

**响应字段（game 对象）**

| 字段 | 类型 | 出现 | 说明 |
|---|---|---|---|
| game_code | string | 总是有 | 全 API 同一把游戏键（`<provider>:<游戏段>`，不透明、原样回传；多产品线厂商如 `db:card-101`） |
| name | string | 总是有 | 官方展示名 |
| provider | string | 总是有 | 厂商代号（厂商族码，来自本面 providers/list 的 code）——转账模式下同时是钱包定位键 |
| provider_product | string | 不一定有 | 该游戏所属的厂商产品维度键——transfers/create 与 wallet/balance 同名参数的**值域来源**（多产品线厂商才出现；db 的产品线：`live` 真人 / `hash` 哈希 / `card` DB 棋牌 / `boyacard` 博雅棋牌 / `fishing` DB 捕鱼 / `boyafishing` 博雅捕鱼 / `slots` 电子 / `scratch` 刮刮乐 / `lottery` 彩票 / `esports` 电竞 / `sports` 体育；其它多产品厂商沿用既有子钱包语义）。不要自行拆 game_code 的游戏段来反推它 |
| category | string | 总是有 | 品类（live 是一等品类）。取值 = 游戏类型字典 17 码：sports, live, slots, lottery, card, poker, fishing, esports, virtual, originals, racing, cockfighting, hash, arcade, table, prediction, scratch；字典只增不改，未知值按「其它」兜底 |
| images.square | string | 不一定有 | 方图 URL——只在厂商提供时出现；缺省 = 厂商无图源，不编图 |
| images.landscape | string | 不一定有 | 横图 URL——同上 |
| locales | array | 总是有 | 支持语言（ISO 639-1 两字母，真数组）。**厂商实例级**：平台运营按厂商实例登记，同一实例下所有游戏同值，**不保证每款游戏各异**；未登记缺省 `["en"]`。不在表内的 locale 启动时静默降级 en |
| currencies | array | 总是有 | 支持币种（ISO 4217 / 加密货币码，真数组）。**厂商实例级**：平台运营按厂商实例登记，同一实例下所有游戏同值，**不保证每款游戏各异**；未登记缺省 `["CNY"]`。以此判断 sessions/create · transfers/create 该传什么币种；实例真正接受的币种以 422 currency_not_supported 为准（`fields.currency` = `temporarily unavailable` 的除外：那是平台侧暂时不可用，不代表实例不收）（目录里出现该实例不接受的币种属运营登记错误，我方告警修正；目录只列部分接受币种是正常经营选择） |
| devices | array | 总是有 | desktop / mobile |
| launch_modes | array | 总是有 | 启动形态能力：含 "html" 即支持 HTML 直出（抗封锁） |
| features | object | 不一定有 | demo / freespins / bonus_buy / jackpot 能力布尔——只在厂商目录给出能力信息时出现；缺省 = 未知，不编假 false（2026-09-19 起由「总是有」改为「不一定有」） |
| bet_levels | object | 不一定有 | 币种 → 合法投注面值（金额字符串数组），只在厂商提供时出现 |
| rtp | string | 不一定有 | RTP 百分数字符串，只在厂商提供时出现；已接 7 家厂商的目录接口均不含 RTP，缺省不编数 |
| volatility | string | 不一定有 | 波动率评级，厂商原值透传（不强行归一枚举） |
| released_at | string | 不一定有 | 发布时刻——此前启动返回 game_not_available |
| forbid_bonus_play | boolean | 不一定有 | true = 禁止用奖金资金游玩（自建奖金系统的合规输入） |
| status | string | 总是有 | active \| recalled——目录只含运营已上架、且所属厂商（多产品线厂商按产品线）当前由平台开放的游戏；active = 厂商在售，recalled = 厂商已下架（下架也是一次变更，增量同步天然捕获）。运营关闭上架的游戏、平台停用的厂商（产品线）的游戏会直接从目录消失，这类消失不经 updated_after 增量下发，由我方商务另行通知；也可自行核对：厂商（产品线）停用时它会从 providers/list 中消失，单款游戏关闭上架可不带 updated_after 全量拉取比对。平台恢复该厂商后，其游戏重新出现、updated_at 随之刷新 |
| updated_at | string | 总是有 | 最后变更时刻——增量同步（updated_after）的锚点 |

### POST `/v1/transfer/providers/list`（上线必须）

厂商目录，**每个厂商族一行**（`pg`、`db`、`pp`、`saba`……）。请求空对象 `{}`。转账模式下 `code` 还是划拨/余额的 `provider` 参数取值。低频，日缓存。

**响应字段（provider 对象）**

| 字段 | 类型 | 出现 | 说明 | 示例 |
|---|---|---|---|---|
| code | string | 总是有 | 厂商族码——transfers / balance 的 provider 参数用它，全 API 的 `provider` 值都来自这里 | "pg" |
| name | string | 总是有 | 官方品牌展示名 | "PG Soft" |
| categories | array | 总是有 | 该厂商的品类 = 本面该族**目录中（运营已上架）游戏 category 的去重集合**；一个类目下最后一款下架即从此消失（可为空数组） | ["slots"] |
| currencies | array | 总是有 | 该厂商支持的币种 = 本面该族各实例登记的 `currencies` 的**并集**（运营按实例登记，未登记缺省 CNY，与 games/list 同源） | ["BRL","USD"] |
| status | string | 总是有 | active \| disabled——**目前恒为 active**：列表只含本面当前开放的厂商族；厂商族整体停用时直接从列表消失、某条产品线停用时从 products 中消失（disabled 暂不下发） | "active" |
| products | array | 不一定有 | 多产品线厂商才出现：`[{code, name}]`，供 lobby 分区与 `provider_product` 取值发现（transfers/create · wallet/balance · wallet/balances.products 的值域） | [{"code":"card","name":"DB Card"}] |

**示例**

```json
// POST /v1/transfer/providers/list   请求 {}
{ "data": [ { "code": "pg", "name": "PG Soft", "categories": ["slots"],
             "currencies": ["BRL","USD"], "status": "active" },
           { "code": "db", "name": "DB Gaming", "categories": ["card","live","fishing"],
             "currencies": ["BRL","USD"], "status": "active",
             "products": [ { "code": "card", "name": "DB Card" },
                           { "code": "live", "name": "DB Live" } ] } ] }
```

### POST `/v1/transfer/currencies`（上线必须）

币种目录 + 每币种 decimals——全 API 金额字符串的精度权威表。请求空对象 `{}`。**没有 K 货币单位**：IDR/VND/COP 型大面值币种直接按真实面值传，不需要 ÷1000 换算。

> **金额请按数值比较，不要按字符串比较**：我们返回的金额小数位按 decimals 写满，**零也一样**（2 位小数币种写 `"0.00"`，如从未上分玩家的 balance）。判断「是否为零」「是否相等」时先解析成十进制数（Java `BigDecimal.compareTo`、Python `Decimal`、JS 用十进制库）再比较；`amount == "0"` 这类字符串比较会出错。

**响应字段（currency 对象）**

| 字段 | 类型 | 出现 | 说明 | 示例 |
|---|---|---|---|---|
| code | string | 总是有 | 法币 ISO 4217 三字母；加密货币 3–4 位字母数字（如 USDT） | "BRL" |
| decimals | integer | 总是有 | 金额字符串小数位数 | 2 |
| type | string | 总是有 | fiat \| crypto | "fiat" |

### POST `/v1/transfer/transfers/create`（上线必须）

划拨：上分/下分合一，方向在 `type`，金额**恒正**。不需要预注册玩家——deposit 自动建户（上游要显式建户的厂商由我们内部代办）。业务结局不是 HTTP 错误：余额不足、局内锁都是 **200 + status:"failed" + reason**。**从未上分（在该厂商尚无钱包）的玩家发起 withdraw 也是业务结局**：200 + status:"failed" + reason:"insufficient_funds"（余额 0 不够下），不是 404——404 player_not_found 只在本平台从未见过该 player_id 时出现，且只发生在 wallet/balance · wallet/balances（见错误码目录）。

**请求字段**

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| player_id | string | 必填 | ≤ 64 字符；首笔划拨确定钱包币种 | "wang-123" |
| provider | string | 必填 | 钱去哪家 / 从哪家取回（厂商族码，来自本面 providers/list 的 code） | "pg" |
| provider_product | string | 多产品线厂商必填 | 钱进哪个产品线钱包。**多产品线厂商必填**（如 db），值取 games/list 回显的 provider_product 或 providers/list.products[].code；单产品线厂商省略。缺省 → 422 validation_failed（fields.provider_product = required）；厂商已开通但值不是它的产品线（如 db + "bogus"）→ 422 validation_failed（fields.provider_product = invalid）；厂商族本身不在本面提供（未接入或已被平台停用）→ 403 provider_not_enabled。三者都不会挂单 | "card" |
| type | string | 必填 | deposit（上分）\| withdraw（下分） | "deposit" |
| amount | string | 必填 | 恒正金额字符串，精度按 currencies。个别游戏厂商的接口能接受的小数位少于币种目录（例如最多 3 位），超出时 transfers/create 当场 422 validation_failed，fields.amount 写明上限（`exceeds N decimals accepted by provider`），不建单、资金未动；改成该位数以内重新发起即可 | "100.00" |
| currency | string | 必填 | 一致性防呆：与该玩家已锁定的钱包币种不符 → 422 currency_mismatch；厂商 / 实例本身不收该币种 → 422 currency_not_supported（两码分工见错误码目录） | "BRL" |
| idempotency_key | string | 必填 | 推荐 tf-{内部订单ID}；拿不到响应同 key 重发恒安全（在同一区内） | "tf-ord-889231" |

**响应字段（Transfer 对象；transfers/get 同形）**

| 字段 | 类型 | 出现 | 说明 | 示例 |
|---|---|---|---|---|
| transfer_id | string | 总是有 | 划拨 ID（trf_），请持久化——transfers/get 的主键 | "trf_01H8XGJWBW" |
| player_id | string | 总是有 | 玩家标识回带 | "wang-123" |
| provider | string | 总是有 | 资金所在厂商（厂商族码，来自本面 providers/list 的 code） | "pg" |
| provider_product | string | 不一定有 | 生效的厂商产品维度键（多产品线厂商时出现）——钱实际进/出的产品线钱包，自记对账以此为准 | "card" |
| type | string | 总是有 | deposit \| withdraw | "deposit" |
| amount | string | 总是有 | 划拨金额（恒正） | "100.00" |
| currency | string | 总是有 | 钱包币种 | "BRL" |
| status | string | 总是有 | completed \| failed 终态不再变；pending 极少出现 → transfers/get 轮询（≥30s 间隔） | "completed" |
| reason | string | 不一定有 | 仅 failed 时出现；枚举见「错误与 reason」一节 | "insufficient_funds" |
| balance | string | 不一定有 | 划拨后（或紧随其后查到的）游戏钱包余额；completed 时通常带上，个别情况下可能缺省（不影响划拨结论）——需要当前余额请调 `wallet/balance`；failed 时给得出则附上 | "100.00" |
| transferable | string | 不一定有 | 可下分预览，厂商有此口径才出现 | "100.00" |
| created_at | string | 总是有 | 受理时刻（RFC 3339） | "2026-07-06T12:00:00.000Z" |
| completed_at | string | 不一定有 | 终态时刻——completed / failed 时出现，pending 时缺省 | "2026-07-06T12:00:00.180Z" |

### POST `/v1/transfer/wallet/balance`（上线必须）

查玩家在指定厂商侧游戏钱包的余额。`transferable`（可下分余额）**按厂商能力出现**：有此口径的厂商透传，给不出的厂商缺省该字段——我们不编数。注意：玩家关闭游戏窗口 ≠ 本局结束，局内锁定余额要等结算。

**从未上分的玩家不是错误**：玩家在该厂商尚无钱包（首笔 deposit / 首次真钱启动才自动建户）→ 200 + balance:"0.00"（transferable 缺省）——0 是事实不是编数，别据此判定玩家不存在。404 player_not_found 只在**本平台从未见过该 player_id** 时出现：你从未用它发起过一次**被受理**的 transfers/create（返回 200，不论厂商、不论 status 是 completed 还是 failed），也从未用它成功建过真钱会话（sessions/create 200）；一旦有过任一次，此后查任何厂商都不会再 404，最多是 0.00。被 4xx 拒绝的请求（422 validation_failed / currency_not_supported、403）在挂单之前就被拦下，**不登记**玩家。

**请求字段**

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| player_id | string | 必填 | ≤ 64 字符 | "wang-123" |
| provider | string | 必填 | 逐厂商钱包：查哪家指定哪家（厂商族码，来自本面 providers/list 的 code） | "pg" |
| provider_product | string | 多产品线厂商必填 | 查哪个产品线钱包的余额。**多产品线厂商必填**（如 db），值取 games/list 回显的 provider_product 或 providers/list.products[].code；单产品线厂商省略。缺省 → 422 validation_failed（fields.provider_product = required）；厂商已开通但值不是它的产品线 → 422 validation_failed（fields.provider_product = invalid）；厂商族本身不在本面提供（未接入或已被平台停用）→ 403 provider_not_enabled。语义同 transfers/create 同名字段 | "card" |

**响应字段**

| 字段 | 类型 | 出现 | 说明 | 示例 |
|---|---|---|---|---|
| player_id | string | 总是有 | 玩家标识回带 | "wang-123" |
| provider | string | 总是有 | 钱包所在厂商（厂商族码，来自本面 providers/list 的 code） | "pg" |
| provider_product | string | 不一定有 | 生效的厂商产品维度键（多产品线厂商时出现）——本快照属于哪个产品线钱包 | "card" |
| currency | string | 总是有 | 钱包币种（单币种钱包） | "BRL" |
| balance | string | 总是有 | 当前余额 | "95.00" |
| transferable | string | 不一定有 | 可下分预览；缺省 = 该厂商无此口径，按 balance 试错 + reason 重试 | "80.00" |
| as_of | string | 总是有 | 快照时刻 | "2026-07-06T12:00:00.000Z" |

### POST `/v1/transfer/wallet/balances`（可选）

一次查玩家在**同一厂商**全部产品/场馆游戏钱包的余额。缺省扇出范围 = 该玩家在该厂商**有过划拨记录的产品/场馆 ∪ 平台缺省产品**；也可用 `products` 显式指定（值域 = games/list 回显的 `provider_product`，或 providers/list.products[].code）。按产品线拆分的厂商（如 db）用 `products` 指定要归集的产品线。单产品/无产品维度厂商退化为单行——等价 wallet/balance。

**边界：厂商内归集**。不解决跨厂商总资产——要看玩家全部厂商的资金，逐厂商各调一次；跨厂商合计（币种折算、时点语义）由你自己定义。

> **partial 反模式警示（务必阅读）**
> 余额是逐产品**顺序**查询的，每行有自己独立的 `as_of`（我们不假装这是同一时点的原子快照）。任何一行失败：该行 `available=false`（保留在列表里，不剔除）、顶层 `partial=true`、**`total_balance` 缺省**。**切勿把可得行的 balance 求和当总资产**——缺的那行可能正是钱所在的场馆。正确姿势：`partial=true` 时按行重试（用 `products` 指定失败行重查），全部可得后再用 `total_balance`。
> 另：本端点较重（要逐个产品查询），勿高频轮询——同一玩家建议间隔 ≥30 秒；高频场景用 wallet/balance 点查目标产品。

**请求字段**

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| player_id | string | 必填 | ≤ 64 字符 | "wang-123" |
| provider | string | 必填 | 归集范围恒为单厂商（厂商族码，来自本面 providers/list 的 code） | "db" |
| products | string[] | 可选 | 显式扇出集（≤32 值，值域 = providers/list.products[].code）；任何非法值（含厂商已开通但不是它的产品线，如 db + "bogus"）→ 422 validation_failed（fields.products），不部分执行；厂商族本身不在本面提供（未接入或已被平台停用）→ 403 provider_not_enabled。缺省 = 划拨触达集 ∪ 平台缺省产品 | ["card", "live"] |

**响应字段**

| 字段 | 类型 | 出现 | 说明 | 示例 |
|---|---|---|---|---|
| player_id | string | 总是有 | 玩家标识回带 | "wang-123" |
| provider | string | 总是有 | 钱包所在厂商（厂商族码，来自本面 providers/list 的 code） | "db" |
| currency | string | 总是有 | 钱包币种（单币种钱包） | "BRL" |
| wallets | array | 总是有 | 逐产品/场馆一行；失败行保留（available=false），不剔除 | — |
| wallets[].provider_product | string | 不一定有 | 该行的厂商产品维度键（无维度厂商的单行不出） | "card" |
| wallets[].balance | string | 不一定有 | 该产品钱包余额；仅 available=true 时出现 | "95.00" |
| wallets[].transferable | string | 不一定有 | 可下分预览（厂商有此口径且行可得时） | "80.00" |
| wallets[].as_of | string | 总是有 | **本行**取数时刻（逐行独立，非全响应统一时点） | "2026-07-06T12:00:00.000Z" |
| wallets[].available | boolean | 总是有 | false = 本行暂不可得（厂商不可达/超时/预算耗尽），balance 缺省 | true |
| total_balance | string | 不一定有 | 全部行可得时的合计。partial=true 时**恒缺省**——勿以可得行之和充总资产 | "97.00" |
| partial | boolean | 总是有 | true = 至少一行不可得；按行重试收敛 | false |

### POST `/v1/transfer/records/list`（上线必须）

**转账模式下游戏期间你是全盲的——这个端点是你知道玩家输赢的唯一渠道**，是主账本不是复核视图。定时批量拉取，按 `record_id` 去重入库。数据从各厂商拉取归一：新鲜度分钟级；可查窗口统一最近 1 个月（`from` 越界请求 422）。个别记录可能因平台侧处理延后出现，处理完成后自动补入、无需商户操作；补入的记录 `recorded_at` 为补入时刻，出现在该时刻所在的时间窗——按时间窗增量拉取不会漏，按 `record_id` 去重即可。顺序稳定但不保证按时间排。

> **时间窗按记录进入本平台的时刻切（2026-10-12）**：`from` / `to` 比较的是每条记录的 `recorded_at`（进入 PlayRouter 的时刻），不是 `started_at`。改判追加的冲销 / 更正记录、体育结算记录、因厂商延迟晚到的记录，都出现在它们**进入时刻**所在的时间窗——按时间窗向前增量拉取不会漏行，无需回拉旧窗口。建议时间窗终点比当前时刻至少早 1 分钟（或相邻时间窗重叠 1 分钟，按 `record_id` 去重）。

> **对账铁律**
> 计算反水或杀率**务必用 `valid_bet`**（有效投注，已排除对押/刷水），不要用 `bet`。记录粒度因游戏类型而异：多数游戏一局一条；捕鱼/老虎机可能整段游玩合并一条；体育投注单与结算单各一条。

> **改判/重结算（追加式，2026-07-19）**
> 厂商终局后改判时**原记录永不修改**——改判发生的时间窗里会追加两条新记录：①**冲销记录**（`source=adjustment`、金额为被冲销记录的**负值**、`ref_record_id` 指向它）；②**更正记录**（改判后的全量值、`ref_record_id` 同指原记录）。你按时间窗增量拉取天然拿到修正，不用回拉旧窗口；对 **status=settled** 的记录求和（Σwin−Σbet、Σvalid_bet）净额恒正确——负值就是为了让最朴素的求和也不出错。示例（原局赢 10 改判为输）：原 `{bet 10.00, win 20.00}` → 追加 `{bet -10.00, win -20.00, source adjustment, ref_record_id}` 与 `{bet 10.00, win 0.00, ref_record_id}`。**负值只出现在 source=adjustment 的冲销记录上**，其余一切金额恒非负。

> **状态与求和口径（2026-07-20 语义收窄）**：金额求和**只算 status=settled 的记录**。`unsettled` 是体育型投注单的首见快照（bet=原注额、win=0、无 settled_at），**首见后不再更新**——结算时另追加一条 settled 全量结算记录（同 provider_record_id/round_id，「投注单与结算单各一条」说的就是这两条）；`canceled` / `refunded` 记录**金额保留原注额**、只是事实快照——**照单全收后务必按 status 过滤，别把作废单的 bet 加进流水**。冲销记录的 status 与被冲销记录一致，同进同出。部分退款不用 refunded：以 settled + 调整后金额的更正记录下发（refunded 仅限全额退）。

**请求字段**

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| from | string | 必填 | 开始时间（含），RFC 3339；比较记录的 `recorded_at`（进入本平台的时刻） | "2026-07-05T00:00:00.000Z" |
| to | string | 必填 | 结束时间（不含）：from ≤ recorded_at < to | "2026-07-06T00:00:00.000Z" |
| cursor | string\|null | 可选 | 黑盒游标，原样传回 | null |
| limit | integer | 可选 | 1–2000 | 2000 |
| player_id | string | 可选 | ≤ 64 字符；按玩家过滤（客诉排查免全量下载） | "wang-123" |
| provider | string | 可选 | 按厂商过滤（厂商族码，来自本面 providers/list 的 code） | "pg" |

**响应字段（Record 对象；records/get 同形）**

| 字段 | 类型 | 出现 | 说明 | 示例 |
|---|---|---|---|---|
| record_id | string | 总是有 | 唯一 ID（rec_）——去重主键、records/get 查询键 | "rec_01H8XGJWBW" |
| player_id | string | 总是有 | 玩家标识 | "wang-123" |
| provider | string | 总是有 | 厂商代号（厂商族码，来自本面 providers/list 的 code） | "pg" |
| provider_product | string | 不一定有 | 厂商产品维度键回显（多产品线厂商才出现，如 db 的 `card`）——按产品线对账用 | "card" |
| game_code | string | 总是有 | 游戏键，与目录一致（不透明键，原样回传，不要自行拆游戏段） | "pg:fortune-tiger" |
| category | string | 总是有 | 品类（值域同 games/list）。按记录的 game_code 查目录得到、入账时快照（日后类目改映不追溯）；目录查不到该码时若该厂商实例只有一个品类（体育 / 真人类大厅厂商）则取它，否则为空串 `""`——键恒在、不编 | "slots" |
| game_detail | string | 不一定有 | 可选。记录带有比游戏键更细的玩法信息（体育项目、桌台、彩种等）时给出其可读名称，只供显示；不要用作分组或关联键，分组请用 `game_code` / `category`。 | "Soccer" |
| currency | string | 总是有 | 币种 | "BRL" |
| round_id | string | 总是有 | 厂商侧回合标识（归一后）。**注意**：单一钱包面（`/v1/seamless/records/*`）同名字段是我方回合 ID（rnd_，与钱包回调同值），两面含义不同，同时接两面时请分开处理 | "rnd_9f2k1" |
| bet | string | 总是有 | 原始投注额（未排除对押）；仅冲销记录（source=adjustment）可为负 | "10.00" |
| valid_bet | string | 总是有 | **有效投注额——反水/杀率用它**；冲销记录的负值使求和净额天然正确 | "10.00" |
| win | string | 总是有 | 派彩额。玩家输赢 = win − bet（可推导，不设差额字段；对负值冲销行同样成立） | "15.50" |
| source | string | 总是有 | game \| freespin \| promo \| adjustment（补偿更正——含**冲销记录：金额为负、恒带 ref_record_id**；可增补） | "game" |
| status | string | 总是有 | unsettled \| settled \| canceled \| refunded | "settled" |
| jackpot.win | string | 不一定有 | 仅该局含彩池派彩时出现（没有平铺恒 0 列） | "3.00" |
| provider_record_id | string | 不一定有 | 厂商侧原始单号——客诉排障时报给我们；改判时同一单号对应多条记录 | "8865512900" |
| ref_record_id | string | 不一定有 | **冲销/更正记录指向被冲销原记录的 record_id**（2026-07-19 加法新增）；原记录永不被修改或删除 | "rec_01H8XGJWBW" |
| session_id | string | 不一定有 | 能归属时给（ses_） | "ses_01H8XGJWBW" |
| started_at | string | 总是有 | 本局开始时间；合并型记录 = 进房时间（照厂商口径） | "2026-07-05T10:11:02.000Z" |
| settled_at | string | 不一定有 | 结算时间；unsettled 时缺省 | "2026-07-05T10:11:05.000Z" |
| recorded_at | string | 总是有 | 记录进入 PlayRouter 的时刻（2026-10-12 新增）——时间窗按它切；改判 / 结算 / 厂商延迟晚到的记录晚于 started_at | "2026-07-05T10:12:30.000Z" |

### POST `/v1/transfer/transfers/get`（可选 · 推荐）

按 `transfer_id` **或** `idempotency_key` 查单笔划拨，最近 1 个月在线可查（更早归档调取，与列表查询窗口同口径）。**这不是重试的前置仪式**——拿不到响应直接同 key 重发 create；本端点服务两个场景：pending 收尾轮询、运维排障反查。查无此单 404 `transfer_not_found`。

**请求字段**

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| transfer_id | string | 二选一 | trf_ 前缀 | "trf_01H8XGJWBW" |
| idempotency_key | string | 二选一 | 用创建时的幂等键反查 | "tf-ord-889231" |

响应 = `{ "data": Transfer }`，与 transfers/create 响应同一对象（不搞两套 schema）。

### POST `/v1/transfer/records/get`（可选 · 推荐）

客诉单查：按 `record_id` 查单笔游戏记录，**最近 1 个月在线可查（更早归档调取，与列表查询窗口同口径）**——不用重拉整段列表定位那一条。响应在 Record 对象之外可选给 `detail_url`（厂商牌局详情页，给客服/玩家看的证据页）；厂商给不出则缺省。查无此单 404 `record_not_found`。

**请求字段**

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| record_id | string | 必填 | rec_ 前缀（records/list 返回） | "rec_01H8XGJWBW" |
| locale | string | 可选 | 详情页语言；不支持降级 en | "pt" |

**响应字段**

| 字段 | 类型 | 出现 | 说明 | 示例 |
|---|---|---|---|---|
| （Record 全字段） | — | 总是有 | 与 records/list 同一对象 | — |
| detail_url | string | 不一定有 | 厂商牌局详情页链接；厂商无详情页则缺省（不编造） | "https://records...." |

### POST `/v1/transfer/sessions/terminate`（可选）

尽力而为的踢出扇出（`player_id` 或 `session_id` 二选一）。能否踢出、踢出后玩家能否重登，效果因厂商而异。**RG/自我排除的硬闸门**在转账模式 = 拒绝该玩家后续 deposit（划拨在你手里）+ 我们对厂商侧封禁通道的扇出。注意：踢出不使游戏提前结算——局内锁定余额仍要等局结束才能下分。

**请求字段**

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| player_id | string | 二选一 | ≤ 64 字符；终止该玩家全部活跃会话 | "wang-123" |
| session_id | string | 二选一 | 指名终止单个会话 | "ses_01H8XGJWBW" |

响应 = `{ "data": {} }`（200 表示扇出已执行，不担保厂商侧踢出结果）。非资金、天然幂等，不带 idempotency_key。

## 错误与 reason

两层分开记：**HTTP 错误**（传输/配置问题，body 为固定 ErrorResponse）和 **reason**（划拨的业务结局，藏在 200 的 `status:"failed"` 里，枚举见下表）。HTTP 层你只需要 4 个分支：

| HTTP | 动作 | 本产品会出现的 code |
|---|---|---|
| 2xx | 用 body（含 status:"failed" 的业务结局） | — |
| 429 | 按 Retry-After 重试 | rate_limited |
| 5xx / 超时 | 同 idempotency_key 重试 | internal_error / provider_error / service_unavailable（provider_timeout 为预留码，目前不会出现） |
| 其余 4xx | 终态：记日志等人修 | 配置或请求问题——逐码表见 附录 · 速查 |

### reason 枚举（划拨的业务结局 · 统一所有厂商的失败形态）

只在 `200 + status:"failed"` 里出现——这不是错误，是合法业务答复；处置分支在「上分 / 下分流程」的取回全部循环里已写全。

| reason | 含义 | 你该做什么 |
|---|---|---|
| insufficient_funds | （下分）游戏钱包余额不足——通常是查询与取款之间玩家有一局结算了 | 重查 balance 再取 |
| game_limit | 该游戏局内禁划拨（斗地主/抢庄牛牛型对局游戏） | 提示玩家退出游戏，或等局结束重试 |
| player_in_game | 玩家正在游戏中，此时不能划拨 | 稍后重试（建议 ≥60s） |
| player_blocked | 玩家被厂商或风控封禁 | 终态；人工处理 |
| amount_out_of_range | 超出厂商单笔/余额上限（目前基本不出现：只在我方人工核对后落终态时可能给出） | 改金额后用新 idempotency_key 重新发起 |
| provider_unavailable | 厂商暂不可用（维护/故障）——资金未动 | 稍后用新 idempotency_key 重新发起（同 key 只会重放这次 failed） |
| provider_rejected | 厂商确定性拒绝（参数/单号/风控类，重试同样被拒） | 终态；记录并人工处理，带 X-Request-Id 报障 |
| not_executed | 这笔划拨确认未被执行——资金未动 | 终态，不会再变；上分退回主钱包扣款、下分不入账；可用新 idempotency_key 重新发起 |

## 附录 · 速查

写代码时贴在手边的两张表。逐字段权威以 playrouter-transfer-wallet-v1.openapi.yaml 为准。

### ID 前缀约定

| 前缀 | 含义 |
|---|---|
| trf_ | 划拨（transfers/create 返回，transfers/get 的查询主键——请持久化） |
| rec_ | 游戏记录（records/list 去重主键、records/get 查询键） |
| rnd_ | 游戏回合（records 内的厂商回合标识，归一后） |
| ses_ | 游戏会话（sessions/create 返回） |
| req_ | 请求追踪（错误体 / 响应头 X-Request-Id） |

没有 `txn_`（零回调，无交易流——输赢在 `rec_` 记录里）；没有 `cmp_`（转账产品无活动模块）。

### 完整错误码目录（27 个）

与 Seamless 产品共用同一错误模型；转账专属 3 码已标注 ★。业务结局（余额不足、局内锁）**不在此表**——那是 200 + status:"failed" + reason，枚举见「错误与 reason」。

| HTTP | code | 触发场景 |
|---|---|---|
| 400 | malformed_request | body 非合法 JSON |
| 401 | invalid_signature | HMAC 对不上 |
| 401 | invalid_timestamp | t 出 ±300s 窗（重放或时钟漂移） |
| 401 | unknown_key | Key-Id 不存在 / 已摘除 |
| 403 | account_suspended | 账号被停用 |
| 403 | feature_not_enabled | 功能未对你开通（转账产品当前无可开通模块——预留码，词汇表统一保留） |
| 403 | provider_not_enabled | 本面当前不提供该厂商（或产品线）——未接入本面，或已被平台停用；以本面 providers/list 为准 |
| 403 | product_not_enabled | 产品线未开通——账号未开通 Transfer 面时，`/v1/transfer/…` 前缀下的**全部**端点（含 sessions / games / providers / currencies）均返本码；已开通的 seamless 面前缀不受影响（契约 v2：面由路径前缀承载） |
| 403 | ip_not_allowed | 来源 IP 不在你为该环境配置的白名单内（可选功能；名单为空不校验；无法判定来源 IP 时同码）——把服务器全部出口 IP（双栈含 v4 与 v6）加入 console「API settings」名单，约 30 秒生效 |
| 404 | not_found | 路径不存在 |
| 404 | game_not_found | game_code 不存在 |
| 404 | player_not_found ★ | 本平台从未见过该 player_id（你从未用它发起过一次被受理的 transfers/create——返回 200、不论 completed / failed——也从未用它成功建过真钱会话；被 4xx 拒的请求不登记玩家）——只在 wallet/balance · wallet/balances 出现。**不是**「在该厂商尚无钱包」：玩家已在平台登记、只是从未在该厂商上分 → balance 回 200 + balance:"0.00"，withdraw 回 200 + status:"failed" + reason:"insufficient_funds"（用户裁决 2026-09-17） |
| 404 | transfer_not_found ★ | transfers/get 查无此单 |
| 404 | record_not_found | records/get 查无此记录 |
| 404 | session_not_found | 引用的会话不存在 |
| 409 | idempotency_key_reused | 同 key 不同 body——修 bug，绝不执行 |
| 413 | payload_too_large | 请求 body 超过大小上限——报文瘦身后重试 |
| 422 | validation_failed | 字段缺失 / 类型错 / 超限（fields 逐字段给出，含时间窗越界）。transfers/create 的 amount 小数位超过该厂商接口能接受的位数（个别厂商少于币种目录，例如最多 3 位）也返本码，fields.amount = `exceeds N decimals accepted by provider`——不建单、资金未动，改成该位数以内重新发起即可 |
| 422 | currency_not_supported | 平台 / 游戏 / 厂商不支持该币种。实例：saba 实例只登记 USD，CNY 上分或 real 启动 → currency_not_supported——处置是换成该厂商支持的币种（看 providers/list.currencies），不是换成玩家钱包币种。**平台侧暂时不可用**时 real 启动也返此码，`fields.currency` = `temporarily unavailable`（固定值，可按它程序判断）——此时**不要换币种**（钱包币种在首次划拨 / 首次真钱启动时锁定：换币种要么撞 currency_mismatch，要么把新玩家锁成错误币种），稍后重试同一请求即可；上下分不受影响。玩家钱包已锁定其它币种时先返 currency_mismatch |
| 422 | currency_mismatch ★ | 请求币种 ≠ 该玩家已锁定的钱包币种（单币种钱包防呆）。实例：玩家首笔划拨锁定 CNY 钱包后再发 USD 请求 → currency_mismatch，message 点名钱包币种（"Wallet currency is CNY."），按它重发 |
| 422 | game_not_available | 游戏存在但当前不可用（下架 / 地域 / 未到 released_at），或厂商拒绝该玩家进入（如玩家账号被停用）——重试同一请求不会成功。**平台侧暂时不可用**时 sessions/create 也返此码，`fields.game_code` = `temporarily unavailable`（固定值，可按它程序判断）——游戏没有下架，不要从大厅撤下，稍后重试同一请求即可；上下分不受影响 |
| 422 | demo_not_available | demo 请求但游戏不支持 |
| 429 | rate_limited | 超配额——按 Retry-After 重试。配额按 API Key 计、以下限保证公布：每 key 每分钟至少 `records/list` 150 次（**本面与单一钱包面的 `records/list` 两面合计**）、`transactions/list` 60 次（单一钱包端点，跨产品面调用同受此档）、其余端点 1000 次（实际可能更高，下限只放宽不收紧）；`RateLimit-*` 头按端点给出勿跨端点缓存，建议（非强制）按 `RateLimit-Remaining` 自适应节流；回补历史可联系临时提额 |
| 500 | internal_error | 我们的故障——同 idempotency_key 重试安全 |
| 502 | provider_error | 上游厂商故障（**仅瞬态**——上游的确定性拒绝会映射为对应 4xx 语义码，不会以 502 出现；收到 502 放心重试） |
| 503 | service_unavailable | 维护 / 过载 |
| 504 | provider_timeout | 预留码，**目前不会出现**；如遇到，同 idempotency_key 重试即可 |

### 划拨业务结局 reason 速查（8 个 · 只在 200 + status:"failed" 出现，正文「错误与 reason」为准）

| reason | 含义 | 你该做什么 |
|---|---|---|
| insufficient_funds | （下分）游戏钱包余额不足 | 重查 balance 再取 |
| game_limit | 该游戏局内禁划拨 | 等局结束重试 |
| player_in_game | 玩家正在游戏中，此时不能划拨 | 稍后重试（建议 ≥60s） |
| player_blocked | 玩家被厂商或风控封禁 | 终态；人工处理 |
| amount_out_of_range | 超出厂商单笔/余额上限（目前基本不出现） | 改金额后换新 key 重新发起 |
| provider_unavailable | 厂商暂不可用（资金未动） | 稍后换新 key 重新发起 |
| provider_rejected | 厂商确定性拒绝（重试同样被拒） | 终态；人工处理 |
| not_executed | 这笔划拨确认未被执行（资金未动） | 终态，不会再变；退回 / 不入账，可换 key 重新发起 |

`status` 全枚举：completed · pending（≥30s 轮询 transfers/get 至终态）· failed（上表 reason）。

> 注：405 / 415 由基础设施层返回、可能不带错误体——按 HTTP 码行动即可，未列入 27 码表。
> 404 也用于**存在但不属于你**的资源（越权响应与"不存在"不可分辨——防跨商户枚举探测）；403 只表示功能 / 厂商未开通类**主体级**权限，不涉及具体资源。

## FAQ

**为什么没有"一键取回全部"（withdraw_all）？**
因为它是我们给不出的承诺："全部"只有在钱包静止时才有确定值，而没有任何厂商保证跨局静止（玩家关窗 ≠ 局结束）。诚实的答案是「取回全部」参考循环——你写一次，全厂商通用，每一步的失败原因都有统一 reason 可分支。

**划拨超时没拿到响应，我该重发还是先查单？**
直接同 idempotency_key 重发，恒安全（在同一区内）——命中会重放原响应并带 `Idempotency-Replayed: true` 头。transfers/get 是给 pending 收尾和排障用的，不是重试的前置步骤。

**status:"pending" 多久出现一次？要怎么处理？**
极少（表示这笔划拨仍在确认中，我方会给出终态）。出现时进你的异常订单队列，用 transfers/get 以 ≥30 秒间隔轮询，终态必然落到 completed 或 failed；终态前不要给玩家主钱包入账。**时限承诺**：pending 超过 1 小时未收敛即转我方人工介入（我方核对后落终态，无需你处理），终态仍通过 transfers/get 给出；受理后如确认这笔划拨未被执行，会以 `failed / not_executed` 结束（通常在受理后十余分钟内）；资金未动：上分退回主钱包扣款、下分不入账，可换新的 idempotency_key 重新发起；这一结论是终态，不会再变。你侧轮询可在 1 小时后降频，不必更密。另：极少数情况下（厂商暂不可用）划拨会直接答 `failed / provider_unavailable`，上下分皆然；这笔首发还在进行时你同 key 重发或查单，可能先看到 `pending`、随后变为 `failed / provider_unavailable`。这一笔资金未动、之后也不会再被执行，按 failed 处置（上分退回主钱包扣款、下分不入账），稍后用新的 idempotency_key 重新发起即可。

**玩家换游戏（跨厂商）时余额怎么办？**
逐厂商钱包：先 `withdraw` 从 A 家取回、确认 completed，再 `deposit` 到 B 家。同厂商内换游戏不需要划拨。建议在玩家离开某厂商游戏时自动触发「取回全部」流程，避免余额长期停在厂商侧。

**为什么 records/list 是必须？我能不能不接？**
不能。转账模式下游戏期间我们不回调你——玩家输赢、有效投注、彩池派彩全部只能从 records/list 拉。不接它你就没有账本，反水、风控、财务对账都无从谈起。

**transferable 缺省的厂商怎么安全下分？**
按 balance 发起 withdraw，失败时看 reason：insufficient_funds 重查再取（余额刚变了）、game_limit / player_in_game 等局结束或稍后重试。「取回全部」参考循环已覆盖这些分支。

**大面值币种（IDR/VND/COP）要除以 1000 吗？**
不要。没有 K 货币单位——金额是字符串十进制，按 /v1/transfer/currencies 公布的 decimals 直接传真实面值。如果你的系统里有 ÷1000/×1000 的换算层，接入时请去掉，那是对账事故的高发源。

**以后想升级 Seamless Wallet（单一钱包）要重接吗？**
目录/会话/币种 5 个端点（sessions/create·terminate、games/list、providers/list、currencies）字段逐字相同，只是路径前缀不同（`/v1/transfer/…` → `/v1/seamless/…`），切换只改前缀。变化：撤掉 transfers / wallet 两组，改为实现 5 个钱包回调；records 两端点单一钱包面也有（`/v1/seamless/records/list` · `get`），字段同名，但 `round_id`、`valid_bet` 的含义不同、资金对账改用 `transactions/list`——见单一钱包文档 records 一节。签名、错误模型、幂等机制不变。

## 下载与资源

- 转账产品 · OpenAPI 3.1：`playrouter-transfer-wallet-v1.openapi.yaml`
- 本页 Markdown 版 · for LLM：`playrouter-transfer-docs.llm.md`（即本文件）

OpenAPI 文件可直接导入 Postman / Insomnia 生成请求集合，11 端点自包含（含签名 / 幂等 / 错误说明），可直接生成客户端 SDK 桩。Markdown 版与本页内容一致，专供投喂 AI 编码助手。

---

PlayRouter Transfer API v1 · 文档站原型（docs.playrouter.io/transfer）· 2026-07-06 · 沙箱 Playground 数据为本页内存模拟，不产生真实请求 · 字段级权威：OpenAPI yaml
