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

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

PlayRouter Seamless Wallet（单一钱包）· 方向 A（你调我们的 15 个端点）+ 方向 B（我们调你的 5 个钱包回调）。接转账钱包？见 Transfer Wallet 文档站（playrouter-transfer-docs.html）。

这份文档面向**运营商的后端工程师**。你不需要读完整规范就能完成对接：本页从零开始讲每个字段是什么、你该传什么/返回什么、以及所有会出错的地方。（HTML 版所有示例可在页内 Playground 直接玩，纯前端模拟，不发网络请求。）

> **先记住四句话（其余都是细节）**
> **① 上线必须只有 4 + 5**（调 4 个端点、实现 5 个回调，其余用到再学）。**② 双向共用同一组密钥、同一个签名算法**。**③ 方向 B 每个回调都回 HTTP 200 + JSON，同一个 `transaction_id` 只记一次账，credit 和 rollback 永远不许拒——唯二例外 player_not_found / currency_mismatch（见附录铁律注）**。**④ 方向 A 是真 HTTP 码**——2xx 用 body、429 等着重试、5xx 同幂等键重试、其余 4xx 修请求。

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

整套对接只有两个方向。**方向 A**：你调用归属服 `{eu|sea|latam}.api.playrouter.io`——启动游戏、拉目录、对账、管活动、彩池 feed；**方向 B**：我们调用你注册的钱包回调——查余额、扣款、入账、结算、冲正。端点按「洋葱」分层：**接通核心层就可以上线**——4 个端点 + 5 个回调；其余端点全部可接可不接（对账三件推荐接，上线后再补也行）。下面每个端点的标题上都标了必须 / 可选。

```
方向 A：你 ──调用──▶ {eu|sea|latam}.api.playrouter.io          （15 个端点：启动 / 目录 / 对账 / 注单 / 活动 / 彩池）
方向 B：PlayRouter ──调用──▶ 你注册的回调 URL   （5 个端点：余额 / 扣 / 入 / 结算 / 冲正）
```

（原文此处为「接入分层洋葱图」：从内到外四层——最内层为核心·上线必须 = 4 + 5（sessions/create · games/list · providers/list · currencies + 方向 B 的 5 个钱包回调）；往外是运营层（强烈建议·可后补：transactions/list · transactions/get · records/list · records/get）；再外是活动模块（可选·启用才认证：campaigns/create · cancel · options · list · get）；最外层是展示/参考·会话辅助（可选：jackpots/feed · sessions/terminate）。从内到外按需剥开，外圈永远不构成上线门槛。）

| 层 | 端点 | 什么时候需要 |
|---|---|---|
| 核心 · 上线必须 | sessions/create · games/list · providers/list · currencies（+ 5 个回调） | 第一天。认证套件核心卷只考这些 |
| 运营 | transactions/list · transactions/get · records/list · records/get | 想做每日对账、查投递日志时（强烈建议，可后补）；想给玩家看游戏记录、按局做统计时接 records（可选） |
| 活动模块 | campaigns/create · cancel · options · list · get | 要发免费旋转/免费筹码/白送功能购买时。不发活动永远不用碰 |
| 展示 / 参考 | jackpots/feed | 想拿彩池当前金额去展示时（跑马灯等是典型用途，不设限） |
| 会话辅助 | sessions/terminate | 风控/自我排除要把人请出游戏时（best-effort） |

## 钱包模式与拓扑

单一钱包（Seamless）模式下，玩家资金**始终且只在你的系统里**——没有厂商侧游戏钱包、没有划拨。玩家的每一笔投注/派彩，由 PlayRouter 实时回调你的钱包记账：**钱不动，账单飞过来**。代价与收益是同一件事：每一笔钱都过你的手（实时、可拒绝、账本天然一致），所以你要运营一个 7×24 可被调用的回调服务。

（原文此处为「单一钱包拓扑图」：你（运营商）持有玩家钱包·唯一账本，资金永远不出你的系统；PlayRouter 居中「吸收厂商混乱」；右侧是几十家游戏厂商。方向 A 是你 → PlayRouter 的 sessions/create；方向 B 是 PlayRouter → 你的 wallet/* 逐笔回调；PlayRouter 与厂商之间是启动游戏与下注/派彩事件。）

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

| 规则 | 含义 |
|---|---|
| **唯一账本** | 玩家余额只有你这一份。我们的每个资金回调都带你答复的最新 `balance`——但记账的真相永远在你的库里。 |
| **7×24 回调服务** | 你要暴露一个回调 base URL（沙箱与生产各一），我们向 `{base}/wallet/*` 五个固定路径发 POST。**回调 base URL 须是最终地址**：我们不跟随 HTTP 跳转（3xx），收到 3xx 按配置故障处理（见「错误与重试」）。宕机也不烧钱：我们按 HTTP 类别解读你的故障并按时间表重试（见「我们对你的承诺」）。 |
| **归属区** | 生产三服 `eu / sea / latam` 全开放，固定用离你钱包后端最近的一个——方向 A 你调它，方向 B 回调从它发出，**双向一个家**，回调出口 IP 白名单只有一段。 |

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

## 五分钟快速开始

1. **拿到密钥对** —— `pk_test_…`（Key-Id，出现在请求头里）和 `sk_test_…`（secret，签名/验签用，**绝不出现在网络上**）。**双向共用这一组**——方向 A 你签名我们验，方向 B 我们签名你验。
2. **注册回调 base URL + 实现验签中间件** —— 沙箱与生产各注册一个，例如 `https://wallet.yourcasino.com`。请填最终地址——不要填会返回 301 / 302 / 307 / 308 的地址（常见于 http 跳 https、换域名、路径末尾自动补斜杠）。我们向 `{base}/wallet/*` 五个固定路径发 POST；五个端点共用同一段验签逻辑（下节，三种语言现成代码）。
3. **实现 5 个钱包回调** —— 一张 `processed_transactions` 幂等表 + 直线记账代码，没有状态机（见方向 B 区，建表 SQL 现成）。
4. **调通 4 个端点** —— `providers/list` + `games/list` 建 lobby（厂商分区 + 游戏目录）、`currencies` 拿金额精度表（方向 B 记账要用）→ `sessions/create` 启动第一局。启动时我们会先回调你的 `wallet/balance` 做预检——第一条回调就这么来（见全景时序）。
5. **跑认证测试套件** —— 沙箱后台一键运行，核心卷只考必须面（启用活动模块才加考活动卷）。全绿 = 自动获得生产密钥申请资格。

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

配合下方「签名」一节的 `signedHeaders`，对沙箱调一次 providers/list——从密钥到 200 的最短路径：

```js
const body = '{}';                                       // 序列化成最终字符串后再签名，不得再格式化
const res = await fetch('https://api.sandbox.playrouter.io/v1/seamless/providers/list', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json',
             ...signedHeaders(body, 'pk_test_01H…', process.env.PR_SECRET) },
  body
});
console.log(await res.json());  // → { "data": [ { "code": "pg", "name": "PG Soft", … } ] }
```

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

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

**transaction_id ＝ 支付流水号**
每个资金回调是一笔流水，这是它的全局唯一编号（`txn_` 前缀），也是**幂等键**：同一编号再来，答 `duplicate:true`、别记账。你的幂等表存的就是它。

**round ＝ 订单号（一局游戏）**
slots 转一次轮、二十一点打一手＝一局。一局产生多笔流水（扣注、派彩、冲正），全挂同一个 `round.id`（`rnd_`）。`sequence`＝本局第几个事件；`finished`＝这局完了没（每局**恰好一次** true）。用途：游戏历史按局分组、未完成局管理、客服对坐标。**不用它账也是对的**——纯分组标签。注意：收口后仍可能到达冲正（rollback）——冲正不改变收口状态、不携带 finished:true，照常按 rollback 处理。

**session ＝ 一次游玩**
玩家点开游戏到离开算一次游玩。`session_id`（`ses_`）是**我们的**编号——排障时报给支持，双方对同一坐标；`session_payload` 是**你的**字条——启动时你塞什么，每个回调原样带回什么（内部会话号/平台/品牌），零查表认路。两个都**零处理义务**，且都不是拒绝资金的理由。

**附赠：campaign_id ＝ 活动编号**
钱源于你创建的活动（免费旋转、免费筹码、白送的功能购买）时出现（`cmp_`）——多活动并行时，这笔派彩算哪个活动的营销成本，到账即知。

## 全景时序：一笔钱怎么流动

四个角色：**玩家**在你的站点玩、游戏由**厂商**提供、**PlayRouter** 居中翻译、**你**只面对统一的 5 个回调。无论厂商怎么发，落到你这里只有 debit / credit / settle 三种直线调用。

（原文此处为「全景时序图」，内容如下：
① **启动游戏**：玩家在你的站点点开「发财虎」→ 你调 sessions/create（方向 A · 带 session_payload）→ PlayRouter 回调你的 wallet/balance 预检（你的第一个回调）→ 返回游戏 URL，玩家进入厂商游戏。
② **一局 · 两段式厂商（主流：下注和派彩分两次）**：玩家按下 spin → 厂商发下注事件（各家格式不同）→ PlayRouter 调你 wallet/debit · amount "10.00" · source "bet" → 你答 200 ok · balance "90.00"；厂商发派彩事件 → PlayRouter 调你 wallet/credit · "24.50" · finished:true 收口 → 你答 200 ok · balance "114.50"。
③ **一局 · 合笔厂商（少数：一局输赢单笔结算）**：玩家按下 spin → 厂商单笔结算：bet 10 · win 24.5 → PlayRouter 调你 wallet/settle · bet "10.00" / win "24.50" · finished:true → 你答 200 ok · balance "114.50"（单 DB 事务）。
厂商怎么发是我们的事；落到你这里永远是上述几种回调。乱序 / 冲正见「我们对你的承诺」一节。）

## 签名（必读 · 双向同一套）

两个方向用**同一组密钥、同一个算法**：HMAC-SHA256 对「时间戳 + 原始请求体」签名，装在两个固定请求头里。方向 A 你给请求签名、我们验（失败 = `401`，语义正确且唯一）；方向 B 我们签名、你验（失败 = `200 + {"status":"error"}`）。验签是你唯一的安全义务——**不需要校验会话、不需要校验 IP、不需要维护 token**。

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

# 验签三步：
# 1. 检查 |服务器当前时间 - t| ≤ 300 秒（防重放）
# 2. 拼接 signed_payload = t + "." + 原始请求体（逐字节，别重新序列化 JSON！）
# 3. HMAC-SHA256(secret, signed_payload) 的 hex 小写 == v1 ？
```

### 方向 A · 给你的请求签名（你 → 我们 · 三种语言）

Node.js：

```js
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" ];
}
```

### 方向 B · 验签（我们 → 你 · 三种语言）

Node.js：

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

function verify(rawBody, sigHeader, secret) {
  const m = /t=(\d+),v1=([0-9a-f]{64})/.exec(sigHeader);
  if (!m) return false;
  const [, t, v1] = m;
  if (Math.abs(Date.now()/1000 - Number(t)) > 300) return false;  // 防重放窗
  const expect = crypto.createHmac('sha256', secret)
                       .update(t + '.' + rawBody).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expect), Buffer.from(v1));
}
// 注意：rawBody 必须是原始字节串。Express 用 express.raw()，
// 不要先 JSON.parse 再 stringify——那会改变字节导致验签失败。
```

Python：

```python
import hmac, hashlib, time, re

def verify(raw_body: bytes, sig_header: str, secret: str) -> bool:
    m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", sig_header)
    if not m: return False
    t, v1 = m.groups()
    if abs(time.time() - int(t)) > 300: return False   # 防重放窗
    payload = t.encode() + b"." + raw_body
    expect = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expect, v1)
```

PHP：

```php
function verify(string $rawBody, string $sigHeader, string $secret): bool {
    if (!preg_match('/t=(\d+),v1=([0-9a-f]{64})/', $sigHeader, $m)) return false;
    [, $t, $v1] = $m;
    if (abs(time() - (int)$t) > 300) return false;   // 防重放窗
    $expect = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    return hash_equals($expect, $v1);
}
```

> **写错也不会出事故**：验签失败你返回 `200 + status:"error"`；就算你错手返回了 401 或 403——我们按**类别**解读：任何 4xx（429 除外——429 按 5xx 进重试时间表）都当作你的配置故障（告警 + 冻结投递，绝不重试轰炸），任何 5xx / 超时进重试时间表。没有会烧钱的错误答案。

## 幂等与重试（总纲）

一套规则、两种钥匙——按方向分工，两边都是「拿不到结果就同键重来，恒安全（在同一区内）」。这里给总图，细节在各自分区展开。

**方向 A · 你是调用方**：幂等键 = body 里的 `idempotency_key`（仅变更类端点，如 campaigns/create）。write-ahead 先落库再派生（推荐 `fs-{内部ID}`），超时/5xx 同 key 原样重发；命中重放返回原响应 + `Idempotency-Replayed: true`；同 key 不同 body → 409 `idempotency_key_reused`；同 key 同 body 但首请求仍在处理中 → 409 `request_in_progress` + `Retry-After`（按头值重试同 key 即命中重放）；保留 7 天。**严禁**每次重试换新 key。详见方向 A 总览。

**方向 B · 我们是调用方**：幂等键 = `transaction_id`（我们生成，就是业务主键，没有第二个键）。你只需一张 `processed_transactions` 表做 effect-once：见过的编号答 `200 + duplicate:true` + 当前余额，不再记账、不要求存原响应。重试节奏由我们按公开时间表执行。详见通用约定与错误与重试。

> **重试判据只看 HTTP 层**：两个方向同一句话：**5xx / 429 / 超时 = 重试（方向 A 的 429 按 Retry-After；方向 B 我们按公开重试时间表）；3xx 与其余 4xx = 修配置，不重试；2xx = 终局，按 body 处理**。方向 B 你恒答 200（业务拒绝是 `declined`，不是错误）——所以你那侧永远不会触发我们的错误重试，宕机时基建吐的 5xx 恰好就是正确信号。

## 方向 A：你调用我们的 15 个端点

Base URL：`https://{eu|sea|latam}.api.playrouter.io`——三个归属服全开放，固定用离你钱包后端最近的一个，用哪个归属就在哪个（沙箱 `https://api.sandbox.playrouter.io`，单区）。改用别的区不需要新账号或新密钥，直接调用新区即可；数据按区存放、区与区不互通——旧区的会话与交易记录留在旧区，照常可查。**拿不到响应要重试时，请留在同一个区**：同一个 `idempotency_key` 只在同一区内去重，换到别的区重发会被当成一笔新请求再执行一次。一律 POST + JSON，带上一节的两个签名头。**路径第二段 = 钱包模式面**：本产品全部端点都在 `/v1/seamless/<域>/<动作>` 下（如 `/v1/seamless/sessions/create`、`/v1/seamless/currencies`）；转账产品的同名端点在 `/v1/transfer/…` 下。账号开通了哪一面才能调哪个前缀——未开通的面一律返回 403 `product_not_enabled`，与端点是否"公共"无关。两面共有的 5 个目录/会话端点字段逐字相同，切换只改前缀。**角色反转提醒**：这一区你是调用方——幂等键是 body 里的 `idempotency_key`（仅变更类端点），错误是**真 HTTP 码** + 语义字符串错误体，别把方向 B 的「恒 200」搬过来。**15 个端点里必须的只有 4 个**（sessions/create · games/list · providers/list · currencies），其余全部可选——每个端点标题都有标注。**字段表读法**（与方向 B 镜像）：请求表的「必填 / 可选」说的是**你**要不要传；响应表的「总是有 / 不一定有」说的是**我们**给不给——「不一定有」判空再用即可。

### 错误处理：你只需要 4 个分支

```
2xx        → 用 body，完事 （200 永远是成功语义，没有"错误藏在 200 里"）
429        → 按 Retry-After 头等待后重试
5xx / 超时 → 用同一个 idempotency_key 重试 （幂等保障下安全）
其余 4xx   → 终态：修请求 / 修配置，记日志
             400 JSON 坏 · 401 签名 · 403 权限未开通 · 404 引用不存在 · 409 幂等键冲突/在途 · 422 字段校验失败
```

每个 4xx/5xx 都是固定形状的错误体——`code` 是语义字符串（不用数字），只进日志不要求逐个 catch；没见过的 code 按其 HTTP 码的动作处理即可（code 增补不是破坏性变更）：

```json
{ "error": {
    "code": "game_not_available",
    "message": "Game 'pg:fortune-tiger' is not enabled for your operator.",
    "doc_url": "https://docs.playrouter.io/errors/game_not_available",
    "request_id": "req_01H…",
    "fields": { "game_code": "not enabled" }
} }
```

（`request_id` 同响应头 X-Request-Id，报障用；`fields` 是 422 时逐字段明细。）

完整错误码目录（30 个逐码表）收在「附录 · 速查」。

### 幂等（变更类端点：campaigns/create · cancel）

body 必填 `idempotency_key`（1–128 字符 `[A-Za-z0-9_.-]`）。推荐确定性派生（如 `fs-issue-{你的内部记录ID}`），**严禁每次重试新生成**。命中重放返回**原响应** + 响应头 `Idempotency-Replayed: true`；同 key 不同 body → `409 idempotency_key_reused`（绝不执行，修 bug）；保留 7 天。5xx / 超时后放心用同 key 重试——绝不会发出两份活动。

**连击（在途撞键）**：同 key 同 body、首请求尚在处理中（原响应还没产生）→ `409 request_in_progress` + `Retry-After` 头（秒）——按头值用**同一个 key** 重试，届时命中重放取回原响应。判定顺序：先比 body（不同 → `idempotency_key_reused`，永久错误）、再看状态（终态 → 重放；在途 → `request_in_progress`，瞬态）。本语义适用于**所有带 idempotency_key 的变更端点**；transfers/create（转账钱包产品）例外——它有 `pending` 单据形态，撞键在途直接 `200 + pending` + `Idempotency-Replayed` 头。

> **限流**：标准响应头 `RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset`；超限 `429` + `Retry-After`。配额**按 API Key 计**（轮换期两把 key 各有独立额度），以**下限保证**公布——每个 API Key 每分钟**至少**：`transactions/list` 60 次、`records/list` 150 次（**本面与转账钱包面的 `records/list` 两面合计**——两面同开的账号共用这一档）、其余端点 1000 次；实际放行量可能更高，下限只会放宽不会收紧。`RateLimit-*` 头的值按端点给出（重查询端点配额更严），勿跨端点缓存；建议（非强制）按 `RateLimit-Remaining` 自适应节流。首次接入回补历史可联系临时提额。

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

启动游戏，真钱/试玩合一。启动时我们统一先回调你的 `wallet/balance` 做预检（所有厂商一致，你无需感知）。**不托管玩家资料**：全 API 的玩家身份面只有一个不透明的 `player_id`——姓名/邮箱/生日/国籍一律不收、PII 不过境。

#### 请求字段

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| player_id | string | real 必填 | ≤ 64 字符；你侧稳定玩家标识，方向 B 回调原样带回；demo 可省略（传了可做统一日志归因） | "wang-123" |
| game_code | string | 必填 | `<provider>:<游戏段>`，来自 games/list——全 API 同一把游戏键。**不透明键，原样回传**：多产品线厂商（目前只有 `db`，DB Gaming）的游戏段形如 `card-101`（产品线-厂商游戏码），单产品线厂商的游戏段就是厂商游戏码原文；不要自行拆游戏段，产品维度用 `provider_product` 字段 | "pg:fortune-tiger" / "db:card-101" |
| currency | string | 必填 | real = 钱包币种，精确匹配永不降级；**同一厂商（多产品线厂商按产品线）内，一个 player_id 只能使用一个币种**，以首次真钱开局为准，沙箱与正式共用这一规则——之后用别的币种在同一厂商开局 → 422 currency_mismatch（message 点名该玩家在这个厂商的币种）；要在同一厂商用多个币种，请按币种拆 player_id（如 wang-123-cny / wang-123-usd），不同厂商之间可以用不同币种（厂商 / 实例不收该币种 → 422 currency_not_supported，在建户 / 打厂商之前拒——如 saba 实例只登记 USD 时的 CNY；该币种平台侧暂时不可用时同码、`fields.currency` = `temporarily unavailable`，不要换币种、稍后重试）；demo = 期望展示币种——厂商不支持则以厂商默认币种（通常 EUR）启动，**静默降级不报错**、不校验 | "BRL" |
| mode | string | 必填 | `real` \| `demo`——真钱/试玩合一端点，模式进字段 | "real" |
| 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 直出（抗封锁，见下述说明）；游戏是否支持看 games/list 的 launch_modes；不支持时返 url 不报错 | "url" |
| ip | string | real 必填 | 玩家 IP（IPv4/IPv6）；demo 可省略 | "203.0.113.7" |
| session_payload | string | 可选 | ≤1024 字符透传串，方向 B 每个回调原样奉还（零查表认路） | "wsess-8f2\|ios" |

#### 响应字段

| 字段 | 出现 | 说明 |
|---|---|---|
| session_id | 总是有 | 我们的会话 ID（ses_）——排障 / terminate / 回调关联的锚点 |
| url | 总是有 | **恒在**——HTML 直出型厂商由我们托管 launcher 包装成 URL，统一契约零分支。**拿到后立即打开、只用一次**：链接有效期由游戏厂商决定，短的只有约 1 分钟，部分厂商的链接只能打开一次；玩家每次进游戏（含刷新页面、重新打开）都重新调用 sessions/create 取新链接，不要缓存复用 |
| html | 不一定有 | launch_mode:"html" 且厂商支持 HTML 直出时给——HTML 启动文档，从你的自有域名伺服。**伺服禁缓存**：每次启动重新取、响应带 `Cache-Control: no-cache, no-store, must-revalidate`（厂商启动令牌一次性，缓存复用会启动失败） |
| expires_at | 总是有 | 启动 URL 最晚失效时刻；实际有效期由游戏厂商决定、可能早得多（见 url 行），不要据此缓存复用 url |

> **降级三连（同一个哲学）**：locale 不支持 → en；demo 币种不支持 → 厂商默认币种；launch_mode:"html" 厂商没有 → 只返 url。**全部静默、不报错**——凡是拒绝调用方毫无收益的地方，一律降级。你不用维护任何厂商白名单。

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

（原文此处为「两种启动形态」对比图：launch_mode:"url"（默认，恒可用）——你的站点 play.yourcasino.com 用 iframe 嵌入响应的 url，最终指向第三方游戏域名，域名可能被墙、封锁单点在别人手里；launch_mode:"html"（进阶·抗封锁）——你的域名直接伺服响应的 html，游戏从你的域名跑，封锁面 = 你自己的域名。我们明令禁止 iframe 包装的假 HTML——iframe 里仍是第三方域名，抗封锁性为零。）

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

游戏目录：cursor 分页 + `updated_after` 增量同步。图、语言、币种、投注面值、features、RTP、启动形态、状态**一个对象拿全**——没有独立面值端点、没有索引往返（campaigns/create 直接传金额字符串）。

#### 请求字段

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| cursor | string | 可选 | 上页响应的 next_cursor；首页传 null 或省略。**黑盒游标**：内容不作承诺，原样传回即可 | "eyJ…" |
| limit | int | 可选 | 每页条数（≤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` 会刷新）。

#### 响应信封

| 字段 | 出现 | 说明 |
|---|---|---|
| data | 总是有 | 游戏对象数组（逐字段见下表） |
| next_cursor | 总是有 | 下一页游标；末页为 null |
| has_more | 总是有 | 还有没有下一页 |

#### 响应的游戏对象（逐字段）

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

> **同步姿势**：首次：读响应头 `X-Full-Dump-Url` 拉每日全量快照（CDN，万级目录不用翻页）；链接下载失败时，改用不带 `updated_after` 的分页全量。日常：`updated_after` 增量 + cursor 翻页。合规不用自算——不可用的游戏启动时返回 `game_not_available`，没有黑白名单矩阵要维护；平台停用的厂商（产品线）的游戏启动时返回 403 `provider_not_enabled`（游戏本身已下架或未上架的仍返回 `game_not_available`）。

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

零参数目录端点——请求就是空对象 `{}`（租户由签名头的 Key-Id 解析，任何端点都不用带租户字段），**每个厂商族一行**（`pg`、`db`、`pp`、`saba`……）。**什么时候用**：后台厂商开关页一次拿全；lobby 厂商分区要的**展示名**（games/list 只给 "pg" 这种机器码，"PG Soft" 在这里）；厂商是否在开放（停用的厂商族直接从列表消失，整区下架以此为判断依据）；多产品线厂商的产品线清单 `products`（lobby 二级分区、`provider_product` 取值发现）。lobby 厂商列表页是行业标配、这三样只有它给——所以它属必须（也是全 API 最便宜的端点：{} 进、对象数组出、日缓存足矣）。

#### 请求字段

空对象 `{}`——零字段（租户由签名头的 Key-Id 解析）。

#### 响应字段（data[] 的厂商对象）

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

#### 示例

```json
// POST /v1/seamless/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/seamless/currencies（上线必须）

同样是零参数端点（请求 `{}`）。**方向 B 的地基**：全 API 金额字符串的小数位权威表——回调金额解析与余额存储精度都依赖它（USDT 这类加密货币不在 ISO 4217 里，精度只能问它）。

#### 请求字段

空对象 `{}`。

#### 响应字段（data[] 的币种对象）

| 字段 | 出现 | 说明 | 示例 |
|---|---|---|---|
| code | 总是有 | 币种码：法币 = ISO 4217 三字母；加密货币 = 3–4 位字母数字 | "BRL" / "USDT" |
| decimals | 总是有 | 金额字符串的小数位数——全 API（含方向 B 回调）的解析与存储精度按此 | 2 / 6 |
| type | 总是有 | `fiat \| crypto` | "fiat" |

#### 示例

```json
// POST /v1/seamless/currencies   请求 {}
{ "data": [ { "code": "BRL",  "decimals": 2, "type": "fiat"   },
           { "code": "USDT", "decimals": 6, "type": "crypto" } ] }
```

### POST /v1/seamless/transactions/list（可选 · 推荐）

对账流：cursor 分页 + 时间窗过滤（from/to 必填），数据源是**我们自己的账本**（非厂商透传）。响应是 Transaction 对象分页（对象全貌见下一节 transactions/get）。

> **transactions 两端点是承诺的落点**：方向 B 的三件事都在这里可见：①每日对账兜底（投递机制失手时，差异在这里捕获、事后 adjustment 修正）；②`delivery_status / delivery_attempts`——每笔回调投没投到你、重试了几次，像 Stripe 的 webhook attempts 一样可查；③钱包回调里刻意不带的元数据（彩池贡献明细 `jackpot`、厂商活动明细 `event`）在 Transaction 对象上承接，数据不丢。

#### 请求字段

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| from | string | 条件必填 | 时间窗起点（RFC 3339，按 occurred_at 过滤）；**不带 round_id 时必填**，带 round_id 时可省略（回合天然有界） | "2026-07-02T00:00:00.000Z" |
| to | string | 条件必填 | 时间窗终点（口径同 from） | "2026-07-03T00:00:00.000Z" |
| round_id | string | 可选 | 按回合过滤（rnd_ 前缀）——回合全貌在此取：回合内多笔按 sequence 排序即可（2026-07-15 修订，原 transactions/get 的回合查询挪到此处） | "rnd_01H…" |
| cursor | string | 可选 | 上页响应的 next_cursor；首页省略或传 null。**黑盒游标**：内容不作承诺，原样传回即可 | "eyJ…" |
| limit | int | 可选 | 每页条数（≤5000） | 2000 |
| player_id | string | 可选 | ≤ 64 字符；按玩家过滤 | "wang-123" |
| provider | string | 可选 | 按厂商过滤（厂商族码，来自本面 providers/list 的 code） | "pg" |

#### 响应字段

| 字段 | 出现 | 说明 |
|---|---|---|
| data | 总是有 | Transaction 对象数组（逐字段见下一节 transactions/get） |
| next_cursor | 总是有 | 下一页游标；末页为 null |
| has_more | 总是有 | 还有没有下一页 |

#### 示例

```json
// POST /v1/seamless/transactions/list  请求：from/to 必填，cursor/limit/player_id/provider 可选
{ "from": "2026-07-02T00:00:00.000Z", "to": "2026-07-03T00:00:00.000Z", "limit": 2000 }
// → { "data": [ /* Transaction 对象 */ ], "next_cursor": "…", "has_more": true }
```

### POST /v1/seamless/transactions/get（可选 · 推荐）

按 `transaction_id` 查单笔详情（点查），**最近 1 个月在线可查（更早归档调取，与列表查询窗口同口径）**、带完整投递日志与 jackpot / event / campaign 元数据。回合维度查询走 transactions/list 的 `round_id` 过滤（2026-07-15 修订：单对象响应装不下回合多笔，get 语义回归点查）。

#### 请求字段

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| transaction_id | string | 必填 | 查单笔（txn_ 前缀） | "txn_01H…" |

#### 响应字段（data 的 Transaction 对象逐字段）

| 字段 | 出现 | 说明 |
|---|---|---|
| transaction_id | 总是有 | 交易 ID（txn_），与方向 B 回调里的同值 |
| type | 总是有 | `debit \| credit \| settle \| rollback`——对应四种资金回调 |
| source | 总是有 | 资金语义标签，与方向 B 的 source 同一枚举族 |
| player_id | 总是有 | 你侧玩家标识（与回调内同值） |
| currency | 总是有 | 交易币种 |
| bet | 不一定有 | 投注金额（金额字符串；type = debit / settle 时出现） |
| win | 不一定有 | 派彩金额（金额字符串；type = credit / settle 时出现） |
| round_id | 不一定有 | 回合 ID（rnd_；无回合事件如小费不带） |
| sequence | 不一定有 | 回合内单调递增序号 |
| round_finished | 不一定有 | 该回合是否已收口 |
| game_code | 不一定有 | 游戏标识（`<provider>:<游戏段>`，不透明键，原样回传） |
| provider | 不一定有 | 厂商代号（厂商族码，来自本面 providers/list 的 code） |
| provider_product | 不一定有 | 厂商产品维度键回显（多产品线厂商才出现，如 db 的 `card`）——按产品线对账用 |
| session_id | 不一定有 | 会话 ID（ses_） |
| occurred_at | 总是有 | 事件发生时刻（RFC 3339） |
| jackpot | 不一定有 | 彩池明细 `{contribution, win, breakdown[{pool_id, contribution, win}]}`——厂商给了才出现；钱包回调刻意不带，数据去处在此 |
| campaign_id | 不一定有 | 源于你的活动时出现（与回调内同值） |
| event | 不一定有 | 厂商侧活动明细 `{id, type, details}`（锦标赛/奖品雨——非你发起，明细走对账不走回调） |
| delivery_status | 总是有 | `pending \| delivered \| retrying \| frozen \| failed`——这笔回调投没投到你。`frozen` = 你的端点返回了 3xx 跳转或非 429 的 4xx（配置故障），我们已暂停自动重试并告警，修复确认后恢复（v1.x 加法新增值，未识别请按未知值兜底） |
| delivery_attempts | 不一定有 | 投递日志数组 `{attempted_at, http_status, outcome}`（像 Stripe 的 webhook attempts） |
| operator_transaction_id | 不一定有 | 你在响应里回执过的账目 ID（对账互查） |

#### 示例

```json
// POST /v1/seamless/transactions/get  请求：
{ "transaction_id": "txn_01H…" }   // 回合维度用 transactions/list + round_id 过滤
// → Transaction 对象（节选关键字段）：
{ "data": {
    "transaction_id": "txn_01H…", "type": "settle", "source": "game",
    "player_id": "wang-123", "currency": "BRL", "bet": "10.00", "win": "24.50",
    "round_id": "rnd_01H…", "sequence": 2, "round_finished": true,
    "jackpot": { "contribution": "0.02", "win": "0.00",
                 "breakdown": [ { "pool_id": "grand", "contribution": "0.02", "win": "0.00" } ] },
    "campaign_id": "cmp_01H…",
    "event": { "id": "evt-prov-881", "type": "Tournament" },
    "delivery_status": "delivered",
    "delivery_attempts": [
      { "attempted_at": "…", "http_status": 504, "outcome": "timeout" },
      { "attempted_at": "…", "http_status": 200, "outcome": "ok" } ],
    "operator_transaction_id": "op-ledger-991" } }
```

### POST /v1/seamless/records/list（可选 · 推荐）

游戏记录（注单）流：**每一局结束后，我们把你这位玩家在这一局的输赢汇成一条记录**（部分游戏按手 / 按注给出），供玩家游戏记录、客服查询与按局统计用。请求字段、分页、时间窗与转账钱包面的 `records/list` 逐字相同；按 `record_id` 去重入库。可查窗口统一最近 1 个月（`from` 越界 422）。

> **注单不是对账依据**：资金以钱包回调（方向 B）与 `transactions/list` 为准。注单只汇总**已入账**的资金流水（你拒掉的扣款不计）；活动 / 奖励类入账（不挂在某一局上的奖金、返现等）不进注单。注单与资金流水的对应关系见下方 `round_id` 一行。

> **什么时候出现**：注单在这一局结束后出现，通常几分钟内；**部分游戏的注单可能在局结束后数小时才出现**（这类游戏的「局结束」要等一段时间没有新的下注 / 派彩才能确认），这类游戏的注单自 2026-10-25 起提供。之后若同一局又有迟到的派彩或冲正，会按下方「改判」规则追加记录。部分彩票、体育类游戏目前只给出有派彩的注单，未中奖的注单暂不出现；这类游戏的下注额以 `transactions/list` 为准。

> **时间窗按记录进入本平台的时刻切**：`from` / `to` 比较的是 `recorded_at`，不是 `started_at`——迟到的记录、改判追加的冲销 / 更正记录都出现在它们进入时刻所在的窗里，按时间窗向前增量拉取不会漏行。建议时间窗终点比当前时刻至少早 1 分钟（或相邻窗重叠 1 分钟，按 `record_id` 去重）。

> **记录粒度因游戏类型而异**：多数游戏一局一条（同一局多人同桌时，每位玩家各一条）；进桌带入、离桌带出的桌游（如棋牌）每手一条；每注独立结算的游戏（如电竞）每注一条，未结算时先给一条 `unsettled` 快照、结算后另追加一条 `settled`。

> **改判（追加式，与转账钱包面同一套规则）**：原记录永不修改；改判时追加①冲销记录（`source=adjustment`、金额为被冲销记录的负值、`ref_record_id` 指向它）与②更正记录（改判后的全量值、`ref_record_id` 同指原记录）。金额求和只算 `status=settled` 的记录，净额恒正确。

#### 请求字段

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| 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，缺省 500 | 2000 |
| player_id | string | 可选 | ≤ 64 字符；按玩家过滤 | "wang-123" |
| provider | string | 可选 | 按厂商过滤（厂商族码，来自本面 providers/list 的 code） | "pg" |

#### 响应字段

| 字段 | 出现 | 说明 |
|---|---|---|
| data | 总是有 | Record 对象数组（逐字段见下表） |
| next_cursor | 总是有 | 下一页游标；末页为 null |
| has_more | 总是有 | 还有没有下一页 |

#### Record 对象（records/list 与 records/get 同形）

字段名与转账钱包面的 Record 相同；**`round_id`、`valid_bet` 两个字段在本面的含义与转账面不同**，同时接两面的请特别留意。

| 字段 | 类型 | 出现 | 说明 | 示例 |
|---|---|---|---|---|
| record_id | string | 总是有 | 唯一 ID（rec_）——去重主键、records/get 查询键 | "rec_01H8XGJWBW" |
| ref_record_id | string | 不一定有 | 冲销 / 更正记录指向被冲销原记录的 record_id | "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）；查不到时为空串 `""` | "slots" |
| currency | string | 总是有 | 币种 | "BRL" |
| round_id | string | 不一定有 | **回合 ID（rnd_）——与钱包回调里的 `round.id`、`transactions/list` 里的 `round_id` 同值**。用它**加上 `player_id`** 调 `transactions/list`，即得这一行涉及的资金流水（同一局多人同桌时各玩家共用一个回合 ID，不带 `player_id` 会把同桌其他玩家的流水一起查出）；本行的 bet / win 只计其中已入账的。不按局结算的游戏（进桌带入、离桌带出的桌游；每注独立结算的电竞）**不带** `round_id`——这类游戏按整场（或整段）汇总核对 | "rnd_01H8XGJWBW" |
| bet | string | 总是有 | 投注额；仅冲销记录（source=adjustment）可为负 | "10.00" |
| valid_bet | string | 不一定有 | **有效投注额——只有游戏本身给出了有效投注时才出现**；拿不到就不给（我们不拿投注额冒充）。不出现时请自定反水口径（通常按 `bet`） | "9.50" |
| win | string | 总是有 | 派彩额；玩家输赢 = win − bet | "15.50" |
| source | string | 总是有 | `game`（常规局）/ `adjustment`（冲销 / 更正）；可增补 | "game" |
| status | string | 总是有 | `unsettled \| settled \| canceled \| refunded`——口径同转账钱包面：金额求和只算 settled；canceled / refunded 金额保留原注额、不参与求和 | "settled" |
| provider_record_id | string | 不一定有 | 厂商侧单号——客诉排障时报给我们 | "8865512900" |
| started_at | string | 总是有 | 本局开始时间 | "2026-07-05T10:11:02.000Z" |
| settled_at | string | 不一定有 | 结束 / 结算时间；unsettled 时缺省 | "2026-07-05T10:11:05.000Z" |
| recorded_at | string | 总是有 | 记录进入 PlayRouter 的时刻——时间窗按它切 | "2026-07-05T10:12:30.000Z" |

#### 示例

```json
// POST /v1/seamless/records/list  请求：from/to 必填，cursor/limit/player_id/provider 可选
{ "from": "2026-07-05T00:00:00.000Z", "to": "2026-07-06T00:00:00.000Z", "limit": 2000 }
// → { "data": [ { "record_id": "rec_01H8XGJWBW", "player_id": "wang-123", "provider": "pg",
//                 "game_code": "pg:fortune-tiger", "category": "slots", "currency": "BRL",
//                 "round_id": "rnd_01H8XGJWBW", "bet": "10.00", "win": "15.50",
//                 "source": "game", "status": "settled", "provider_record_id": "8865512900",
//                 "started_at": "…", "settled_at": "…", "recorded_at": "…" } ],
//     "next_cursor": "…", "has_more": true }
// 这一局的资金流水：POST /v1/seamless/transactions/list {"round_id":"rnd_01H8XGJWBW","player_id":"wang-123"}
```

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

客诉单查：按 `record_id` 查单笔游戏记录，**最近 1 个月在线可查（更早归档调取，与列表查询窗口同口径）**。查无此记录（含不属于你的记录）404 `record_not_found`。

#### 请求字段

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| record_id | string | 必填 | rec_ 前缀（records/list 返回） | "rec_01H8XGJWBW" |
| locale | string | 可选 | 预留（详情页语言）；目前无详情页，可省略 | "pt" |

#### 响应字段

| 字段 | 出现 | 说明 |
|---|---|---|
| data | 总是有 | Record 对象（与 records/list 同一形状） |

### POST /v1/seamless/campaigns/create（可选 · 发活动才接）

一个模型发三种赠品：`type: freespin | freechip | bonus_buy`（与方向 B 的派彩 source 一一对称：freespin↔freespin_payout、freechip↔freechip_payout、bonus_buy↔bonus_buy_payout）。**名单内嵌**：`player_ids` 随 create 提交（≤1000/次）——给 1000 人发 = 1 次调用，逐玩家成败在响应 `results` 给出（整批 2xx，单人失败不连坐）。

#### type 决定两个骨架字段的语义

| type | bet_amount 是什么 | free_game_count 是什么 | 典型 provider_options |
|---|---|---|---|
| freespin | 单次免费旋转的投注额 | 旋转次数（必填） | 厂商倍数等 |
| freechip | 单枚筹码面值（须 ≥ 游戏最小注，厂商侧校验） | 筹码枚数（必填） | max_win 等 |
| bonus_buy | 功能购买的基础投注额（成本 = 基础注 × 厂商 cost_multiplier） | 固定 1，可省略 | bonus_type（值域经 options 的 schema enum 发现） |

#### 请求字段

| 字段 | 必填 | 说明 | 示例 |
|---|---|---|---|
| idempotency_key | 必填 | 幂等键（见方向 A 总览）——5xx/超时后同 key 重试，绝不发出两份活动 | "camp-2026w27-vip" |
| type | 必填 | freespin \| freechip \| bonus_buy | "freespin" |
| name | 必填 | 活动名——create 回执与对账日志里的可读标签 | "vip-july-freespins" |
| provider | 必填 | 厂商显式单列（厂商族码，来自本面 providers/list 的 code；所有 game_codes 必须属于它，校验更早） | "pg" |
| game_codes | 必填 | 游戏数组（同一厂商） | ["pg:fortune-tiger"] |
| currency | 必填 | 一活动一币种（bet_amount 语义无歧义）；多币种发放 = 多个活动 | "BRL" |
| free_game_count | 按 type | **整个活动的总量**——玩家可在 game_codes 内任意游戏消耗，**不是每游戏各一份** | 10 |
| bet_amount | 必填 | 金额字符串。games/list 该游戏带 `bet_levels` 时从中选值；不带（厂商不给面值表，属常态）则按厂商活动文档给的合法面值填——金额→厂商索引的翻译我们做 | "0.50" |
| starts_at | 必填 | 活动开始时刻（起止都有 = 可预建定时活动） | "2026-07-04T00:00:00.000Z" |
| ends_at | 必填 | 活动截止时刻。厂商时长上限先查 options 的 constraints；违规 → 422 + fields.ends_at 明细 | "2026-07-11T00:00:00.000Z" |
| player_ids | 必填 | 发放名单（≤1000/次）。活动 = **一次性名单快照**——中途追加 = 另建活动 | ["wang-123","li-456"] |
| provider_options | 可选 | 类型/厂商专属参数，按 options 返回的 (provider, type) JSON Schema 校验；不合 → 422 provider_options_invalid | {"multiplier":10} |

#### 响应字段

| 字段 | 出现 | 说明 |
|---|---|---|
| data.campaign_id | 总是有 | 活动 ID（cmp_）——cancel 的唯一引用键，**须持久化** |
| data.status | 总是有 | 初始恒 `pending`；状态全枚举 `pending \| started \| ended \| cancelled`（经 create / cancel / get / list 响应可见） |
| data.results[] | 总是有 | 名单逐玩家答复 `{player_id, status: ok\|failed, code?}`——整批 2xx、单人失败不连坐；code 见下表。create 首次应答只反映受理校验，厂商侧逐玩家结果在投放后经 campaigns/get 给出 |

> **响应仍须持久化 · 认证套件必测**：响应里的 `campaign_id` 是 cancel 的**唯一引用键**。响应丢失时 7 天内可凭同 idempotency_key 重放找回；超期可经 campaigns/list 按名称/时间定位、campaigns/get 取回全量（v1.x 纯加法补齐，2026-07-09——查询面是兜底，不是持久化义务的替代）。

> **厂商暂不支持活动发放时**：campaigns/options 对该厂商回空数组，即该厂商暂不支持活动发放；此时建活动返回 403 `feature_not_enabled`——不是你的配置问题，等我方通知开通即可。

#### results：200 里唯一的逐项业务失败

| results[].code | 语义 |
|---|---|
| player_id_invalid | player_id 格式不合法（受理校验；预留码——目前名单含不合法 ID 时整单回 422 validation_failed） |
| campaign_player_limit_reached | 活动人数达厂商上限，本项未加入 |
| provider_rejected | 厂商拒绝向该玩家发放——投放后才知道，经 campaigns/get 查看；本项未发放，名单内其余照常生效 |

（原文此处为「名单内嵌 create」流程图：你调 campaigns/create · type + player_ids（≤1000）+ idempotency_key → PlayRouter 受理校验名单 → 返回 200 · campaign_id + results（受理校验逐项：全部 ok）——单人失败不连坐 → PlayRouter 按投放批次逐玩家发放（翻译成厂商的单玩家调用 / 面值索引）→ 某个玩家被厂商拒绝时，经 campaigns/get 的 results 给出 failed(provider_rejected)，名单内其余照常生效。…几天后玩家把免费旋转玩完，厂商发结算 → PlayRouter 回调你 wallet/credit · source:"freespin_payout" · campaign_id ← 归因闭环。发放（方向 A）与结算（方向 B）由 campaign_id 串成闭环——营销成本归哪个活动，到账即知。）

（原文此处为「活动状态机」图：pending →（starts_at 到点）→ started →（ends_at 到点）→ ended；pending 和 started 均可经 campaigns/cancel → cancelled。字符串枚举，不用数字码。pending 必可取消；started 取消受理即回执终态 cancelled（厂商侧未使用发放的撤回由平台后台完成）——状态经 create / cancel / get / list 响应可见。）

### POST /v1/seamless/campaigns/cancel（可选 · 发活动才接）

取消活动（变更类，带幂等键）。请求就两个字段：`idempotency_key` + `campaign_id`（create 响应发的 cmp_，全局唯一，不需要厂商辅助定位）。

#### 请求字段

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| idempotency_key | string | 必填 | 幂等键（变更类统一规则，见方向 A 总览） | "cancel-cmp01H" |
| campaign_id | string | 必填 | 要取消的活动（create 响应发的 cmp_，全局唯一） | "cmp_01H…" |

#### 响应字段

| 字段 | 出现 | 说明 |
|---|---|---|
| data.campaign_id | 总是有 | 回显活动 ID |
| data.status | 总是有 | 终态 `"cancelled"`——回执带终态，不用猜取消成没成 |

> **取消承诺**：`pending` 状态**必可取消**（活动还没到厂商，我们自己说了算）；`started` 取消**受理即回执终态 `cancelled`**，厂商侧未使用发放的撤回由平台后台异步完成（撤回失败自动重试；发放随活动截止时刻自然失效兜底），你无需跟进。`422 campaign_state_invalid` 只余两种形态：活动已 `ended`；或该厂商没有撤回能力（options 的 `constraints.cancellable` 为 false，可先查）时取消 `started` 活动。对已取消的活动再次取消，返回同一终态回执 `cancelled`，不报错。

### POST /v1/seamless/campaigns/options（可选 · 发活动才接）

厂商参数发现，按 `(provider, type)` 组合返回两样东西：`json_schema` 管「怎么填合法」——SDK 发请求前本地校验 provider_options；`provider_data` 管「填哪个划算/可用」——bonus_buy 的逐游戏成本表 `cost_multipliers`、freechip 的动态面值 `chip_values`、通用 `constraints`（时长上限、可否取消等 JSON Schema 表达不了的约束）。`data` 恒为数组：指定 type = 单元素，缺省 = 该厂商全部 type 逐项。

> **回空数组 = 该厂商暂不支持活动发放**：查选项对某厂商回空数组时，即该厂商暂不支持活动发放；此时对它建活动返回 403 `feature_not_enabled`——不是你的配置问题，等我方通知开通即可。

#### 请求字段

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| provider | string | 必填 | 查哪家厂商（厂商族码，来自本面 providers/list 的 code） | "pg" |
| type | string | 可选 | freespin \| freechip \| bonus_buy；缺省 = 返回该厂商全部 type 逐项 | "freespin" |

#### 响应字段（data[] 按 (provider, type) 逐项）

| 字段 | 出现 | 说明 |
|---|---|---|
| provider | 总是有 | 本项对应的厂商 |
| type | 总是有 | 本项对应的类型（freespin \| freechip \| bonus_buy） |
| json_schema | 总是有 | 该组合下 provider_options 的 JSON Schema——SDK 发请求前本地校验；值域类信息（bonus_type 枚举、离散面值）经 enum 承载 |
| provider_data | 总是有 | 数据类发现信息，无数据时为 null（以下三个子字段按需出现） |
| provider_data.cost_multipliers[] | 不一定有 | type=bonus_buy——逐游戏成本表 `{game_code, bonus_type, multiplier}`（发放成本 = bet_amount × multiplier） |
| provider_data.chip_values | 不一定有 | type=freechip——动态面值厂商按币种的合法面值数组 |
| provider_data.constraints | 不一定有 | 通用厂商约束：`max_duration_days`（活动时长上限）· `cancellable`（可否取消）等——JSON Schema 表达不了的跨字段/能力类限制，创建前自查 |

### POST /v1/seamless/campaigns/list（可选 · 发活动才接）

活动列表查询（v1.x 纯加法补齐，2026-07-09——"最小面"原则下实需回补的演进案例）：cursor 分页列出你创建过的活动，`status` / `type` / `provider` / 创建时间窗可过滤。**典型用途**：create 响应丢失且幂等超期后按名称/时间找回 `campaign_id`；营销后台的活动列表页。只读、无幂等键。返回**活动骨架**（不含逐玩家 results——全量用 campaigns/get）。

#### 请求字段

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| cursor | string | 可选 | 上页 `next_cursor` 原样带回 | "eyJv…" |
| limit | integer | 可选 | 每页条数，默认 20、上限 100 | 20 |
| status | string | 可选 | 过滤：pending \| started \| ended \| cancelled | "started" |
| type | string | 可选 | 过滤：freespin \| freechip \| bonus_buy | "freespin" |
| provider | string | 可选 | 过滤：厂商代号（厂商族码，来自本面 providers/list 的 code） | "pg" |
| from | string | 可选 | 创建时间窗起（RFC 3339，按 created_at 过滤） | "2026-07-01T00:00:00.000Z" |
| to | string | 可选 | 创建时间窗止 | "2026-07-09T00:00:00.000Z" |

#### 响应字段（data[] 的活动骨架；外层 `next_cursor` / `has_more` 与 transactions/list 同款）

| 字段 | 出现 | 说明 |
|---|---|---|
| campaign_id | 总是有 | cmp_ |
| name / type / provider / currency | 总是有 | create 时所传原值 |
| status | 总是有 | 当前状态（全枚举见 create） |
| starts_at / ends_at | 总是有 | create 时所传原值 |
| created_at | 总是有 | create 受理时刻（本次加法新增字段，list/get 同出） |
| player_count | 总是有 | 名单人数（逐玩家明细用 campaigns/get 取） |

### POST /v1/seamless/campaigns/get（可选 · 发活动才接）

按 `campaign_id` 查单个活动全量（v1.x 纯加法补齐，2026-07-09）：create 请求回显 + 当前 `status` + 名单逐玩家 `results`（与 create 响应同构，反映发放时点结果）。查无此单或不属于你 → 404 `campaign_not_found`（防枚举口径，决策 42）。只读、无幂等键。

#### 请求字段

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| campaign_id | string | 必填 | create 响应发的 cmp_（或经 campaigns/list 找回） | "cmp_01H…" |

#### 响应字段

| 字段 | 出现 | 说明 |
|---|---|---|
| campaign_id / name / type / provider / game_codes / currency / bet_amount / free_game_count / starts_at / ends_at / provider_options | 总是有（free_game_count / provider_options 随 create 传否） | create 请求回显 |
| status | 总是有 | 当前状态 |
| created_at | 总是有 | create 受理时刻 |
| results[] | 总是有 | 名单逐玩家 `{player_id, status: ok\|failed, code?}`——与 create 响应同构 |

### POST /v1/seamless/jackpots/feed（可选）

彩池当前金额的只读缓存 feed（TTL 分钟级）。**典型用途**是 lobby 展示彩池金额（跑马灯、游戏卡角标等）——数据怎么用是你的自由，我们只设一条硬约束：**非结算依据**（派彩以方向 B 的 credit(source=jackpot) 回调为准）。`currency` 必填单币种查询，`amount` 以请求币种计（换币种再查即可）。`pool_id` 与对账里 `Transaction.jackpot.breakdown[].pool_id` 同一命名空间——feed、派彩、对账三处对得上。无彩池数据的厂商游戏不出现在响应中。

#### 请求字段

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| currency | string | 必填 | 查询币种——amount 以它计价（换币种再查） | "BRL" |
| provider | string | 可选 | 按厂商过滤（厂商族码，来自本面 providers/list 的 code） | "pp" |
| game_code | string | 可选 | 按游戏过滤（不透明键，原样传） | "pp:jackpot-game" |

#### 响应字段（data[] 逐游戏条目）

| 字段 | 出现 | 说明 |
|---|---|---|
| game_code | 总是有 | 带彩池的游戏（与 games/list 同一把键） |
| provider | 总是有 | 厂商代号（厂商族码，来自本面 providers/list 的 code） |
| pools[] | 总是有 | `{pool_id, amount}`——pool_id 与对账 Transaction.jackpot.breakdown[].pool_id 同一命名空间；amount 以请求币种计（金额字符串） |
| updated_at | 总是有 | 数据时刻（缓存 TTL 分钟级）——非结算依据 |

### POST /v1/seamless/sessions/terminate（可选 · best-effort）

把玩家请出游戏（风控/自我排除场景）。请求给 `player_id`（终止其全部活跃会话）或 `session_id`（指名终止）二选一；非资金、天然幂等，不带 idempotency_key；响应 `200 + {"data":{}}`（受理即 200，不担保厂商侧结果）。

#### 请求字段

| 字段 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
| player_id | string | 二选一 | ≤ 64 字符；终止该玩家的全部活跃会话 | "wang-123" |
| session_id | string | 二选一 | 指名终止单个会话（ses_ 前缀，sessions/create 响应发的） | "ses_01H…" |

#### 响应字段

| 字段 | 出现 | 说明 |
|---|---|---|
| data | 总是有 | v1 为空对象 `{}`——受理即 200（best-effort，不担保厂商侧踢出结果）；演进按加法补逐厂商扇出结果 |

> **语义边界 · 别搞混两层**：这是**尽力而为的踢出扇出**（能否踢出、踢出后玩家能否重登，效果因厂商而异）。**RG/自我排除的硬闸门永远在你的钱包侧**：对 debit 答 `declined(player_blocked)`——实时、资金边界强制、不依赖任何厂商。terminate 是体验层（把人请出桌），player_blocked 是资金层（让他玩不了）。

## 方向 B：实现 5 个钱包回调（通用约定）

从这里到「错误与重试」都是方向 B——我们调用你注册的 `{base}/wallet/*`。角色再次反转：**恒 200 + JSON**（业务拒绝放 body）、幂等键是 `transaction_id`（我们生成）、你只会写三种响应。**这 5 个回调全部属上线必须面**——资金边界没有可选项。

### 响应契约：你只会写三种 JSON

```json
// ① 办成了（每个响应必带 status 和 balance）
200 { "status": "ok", "balance": "114.50", "operator_transaction_id": "op-991" }   // 末字段可选

// ② 业务上拒了（balance / debit / settle 可拒；credit / rollback 仅 player_not_found / currency_mismatch 可拒——账本无法定位）
200 { "status": "declined", "reason": "insufficient_funds", "balance": "3.20" }

// ③ 验签失败（唯一的 error）
200 { "status": "error", "reason": "invalid_signature" }
```

| reason（declined 时） | 什么时候用 | balance 要不要带 |
|---|---|---|
| insufficient_funds | 余额不够扣 | **必带**（游戏客户端靠它刷新显示） |
| player_blocked | 玩家被禁投 / 自我排除 | **必带** |
| bet_limit_exceeded | 责任博彩限额（自设或监管） | **必带** |
| player_not_found | 玩家在你系统里不存在 | 可省（钱包定位不到） |
| currency_mismatch | 交易币种不在该玩家支持的币种范围内（玩家可多币种） | 可省 |

### 金额与时间

| 类型 | 格式 | 示例 |
|---|---|---|
| 金额（请求） | 字符串十进制，`^\d{1,18}(\.\d{1,12})?$`，**恒非负**——方向由端点表达（debit 恒扣、credit 恒入），你永远不用判断正负号 | "10.00" · "0.000001" · "0.00" |
| balance（响应） | 同上，但**唯一允许负号**（credit/rollback 强制执行后可能为负，见铁律①） | "114.50" · "-95.00" |
| 时间 | RFC 3339 UTC 恒 3 位毫秒，字段名以 `_at` 结尾 | "2026-07-03T12:00:00.000Z" |

> **金额请按数值比较，不要按字符串比较**：我们发出的金额（回调请求、各查询端点的响应）小数位按 `currencies` 公布的 decimals 写满，**零也一样**（2 位小数币种写 `"0.00"`，6 位写 `"0.000000"`）。判断「是否为零」「两笔是否相等」时，先解析成十进制数（Java `BigDecimal.compareTo`、Python `Decimal`、JS 用十进制库）再比较；`amount == "0"` 这类字符串比较会出错。

### 幂等：一张表就够

每个资金回调带全局唯一的 `transaction_id`（`txn_` 前缀）。网络重试时同一笔会再来——你要保证**只记一次账**，重复的答 `duplicate: true` + 当前余额。**不需要保存原响应体**。

```sql
CREATE TABLE processed_transactions (
  transaction_id  VARCHAR(64) PRIMARY KEY,   -- txn_… 直接做主键，天然去重
  type            VARCHAR(16)  NOT NULL,     -- debit | credit | settle | rollback
  player_id       VARCHAR(100) NOT NULL,
  processed_at    TIMESTAMPTZ  NOT NULL DEFAULT now()
);
-- handler 伪代码：INSERT … ON CONFLICT DO NOTHING；
-- 冲突（已存在）→ 返回 { ok, 当前余额, duplicate:true }，什么都不改。
```

## 收到任何回调时，按这个顺序判断

1. **JSON 解析失败？** → 返回 `400`（框架默认行为即可，零代码）。
2. **验签失败 / 时间戳出窗？** → `200 + {"status":"error","reason":"invalid_signature"}`。
3. **transaction_id 已处理过？** → `200 + {"status":"ok","balance":当前余额,"duplicate":true}`，到此为止，什么都不改。
4. **业务检查（仅 balance / debit / settle）**：玩家存在吗？币种对吗？余额够吗？限额允许吗？不行 → `200 + declined + reason`。**credit 和 rollback 跳过这一步**——它们没有拒绝路径。
5. **单个 DB 事务里记账**：动余额 + 写幂等表，一起提交。→ `200 + ok + 最新余额`。
6. **自己内部炸了？** → 返回 `5xx`。我们会按 5s→1m→5m→…→24h 的时间表自动重试（全表见附录重试时间表；幂等表保证重试安全）。

## 我们对你的承诺（这些麻烦你永远见不到）

游戏厂商的事件是乱的：win 可能先于 bet 到达、rollback 可能引用一笔从未送达的交易。**这些混乱全部在 PlayRouter 层终止**——右边（你）永远只看到干净有序的流。

（原文此处为「乱序吸收」示意图：厂商（混乱侧）的 win #7 先到（它的 bet #6 还没来）→ PlayRouter 照常发 credit txn_7（金额自足，直接入账）；bet #6 迟到 2.4 秒 → 发 debit txn_6（回合内 FIFO · sequence 对齐）；厂商发 rollback(bet #88) 而 #88 从未送达过 → PlayRouter 吸收 · 墓碑登记，你这里什么都没发生。三条书面承诺：① 绝不对你未收到的交易发 rollback ② rollback 后原交易绝不再来 ③ 引用完整性 + 回合内按序投递（带单调 sequence）。）

> **对你意味着**：不用写乱序状态机、不用建墓碑表、不用兜底"未知交易的冲正"——**来一条记一条账**。收口也有保证：每个回合最终**恰好一次** `round.finished: true`（收口关闭的是游戏流；收口后若来迟到冲正，照常按 rollback 处理即可，不影响该保证）。

## source 全解：每种钱是什么、走哪个端点

端点名回答"钱往哪个方向动"（debit 扣 / credit 入 / settle 一扣一入），`source` 回答"这是什么钱"。你的记账代码**不需要按 source 分支**——它只是写进账本的分类标签（报表、返水口径、风控用）。铁律③：没见过的 source 值照常入账/扣款。

### 端点 × source 矩阵

| source | 走哪个端点 | 这是什么钱 | 带 round? | 带 campaign_id? |
|---|---|---|---|---|
| bet | debit | 游戏下注（两段式厂商的扣款） | 有 | 无 |
| bonus_buy | debit | 功能购买——玩家花钱直接买进免费特性玩法（如 100 倍注价直接进免费旋转） | 有 | 无 |
| tip | debit | 真人桌小费（给荷官）。不属于任何一局，可被冲正 | 无 | 无 |
| entry_fee | debit | 锦标赛/活动报名费 | 无 | 可能有 |
| adjustment | debit 或 credit | 事后更正（真人桌复核改判等）：要收回走 debit、要补发走 credit——**唯一双向的 source**，方向永远由端点表达，你不用碰负数 | 通常有 | 无 |
| game_win | credit | 游戏派彩（两段式厂商的赢钱），常带 related_transaction_id 指回原下注 | 有 | 无 |
| freespin_payout | credit | 免费旋转结算——你发的赠品旋转玩完，总赢额一笔入账（**之前没有对应 debit**：玩的是赠品不是玩家的钱） | 可能无 | 有 |
| freechip_payout | credit | 免费筹码结算——同上，赠品形态是筹码（桌面/真人用） | 可能无 | 有 |
| bonus_buy_payout | credit | 功能购买的派彩。玩家自费买的配对 bonus_buy 扣款；你活动白送的则无扣款、带 campaign_id | 有 | 白送时有 |
| promo_win | credit | 活动奖金：锦标赛奖金、奖品雨、厂商促销。不属于任何一局 | 无 | 你办的活动才有 |
| jackpot | credit | 彩池派彩（累积奖金命中）。池明细不进回调，走对账 transactions/get | 有 | 无 |
| round_finish | credit | 零额回合收口——没钱要动但这局该关了（amount 为零，如 "0.00"；余额不变），读 round.finished:true 关局即可 | 有（finished:true） | 无 |
| game | settle | 合笔结算（少数厂商）：一局的扣与派一笔完成。settle 只有这一个 source | 有 | 无 |

### 实际调用长什么样（公共信封字段省略，只看区别）

```json
// ① 普通一局（两段式厂商）：一扣一入，related 指回原注
debit  { "amount": "10.00", "source": "bet",      "round": {"id":"rnd_X","sequence":1,"finished":false} }
credit { "amount": "24.50", "source": "game_win", "round": {"id":"rnd_X","sequence":2,"finished":true},
         "related_transaction_id": "txn_A" }

// ② 同一局，换成合笔厂商：一笔完成
settle { "bet": "10.00", "win": "24.50", "source": "game", "round": {"id":"rnd_X","sequence":1,"finished":true} }

// ③ 功能购买：买入 + 派彩，一对
debit  { "amount": "100.00", "source": "bonus_buy",        "round": {"id":"rnd_Y","sequence":1,"finished":false} }
credit { "amount": "284.00", "source": "bonus_buy_payout", "round": {"id":"rnd_Y","sequence":2,"finished":true},
         "related_transaction_id": "txn_C" }

// ④ 免费旋转结算：只有入账、没有扣款（玩的是你送的赠品），campaign_id 告诉你是哪个活动
credit { "amount": "37.80", "source": "freespin_payout", "campaign_id": "cmp_vip_july" }

// ⑤ 真人桌小费：无 round（不属于任何一局），之后可能被冲正
debit    { "amount": "5.00", "source": "tip" }
rollback { "original_transaction_id": "txn_E" }   // 荷官没收到，退回小费

// ⑥ 锦标赛：报名扣费 → 得奖入账（两笔独立，无 round）
debit  { "amount": "20.00",  "source": "entry_fee", "campaign_id": "cmp_weekly_t" }
credit { "amount": "500.00", "source": "promo_win", "campaign_id": "cmp_weekly_t" }

// ⑦ 事后更正（真人桌复核）：补发走 credit、收回走 debit——同一个 source，方向在端点
credit { "amount": "1.11", "source": "adjustment", "round": {"id":"rnd_Z","sequence":3,"finished":true} }
debit  { "amount": "1.11", "source": "adjustment", "round": {"id":"rnd_Z","sequence":3,"finished":true} }

// ⑧ 零额收口：这局没钱可动但该关了（厂商结束信号迟到时）
credit { "amount": "0.00", "source": "round_finish", "round": {"id":"rnd_W","sequence":2,"finished":true} }
```

> **给记账代码的结论**：八个场景，你的 handler 还是那三段直线代码：debit 扣、credit 入、settle 一扣一入。source 原样写进账本即可——它服务的是**报表和运营**（哪些钱算流水、哪些算营销成本、哪些是更正），不服务记账分支。

## 5 个回调 · 端点参考

（HTML 版此处每个端点都有可交互 Playground，共享同一个沙箱钱包状态栏「沙箱钱包 · wang-123 · BRL · 100.00」；此处仅保留规则性文字。）

**字段表读法**：请求表的「总是有 / 不一定有」说的是**我们**传不传——「总是有」直接用、「不一定有」判空再用，两种都不构成拒绝理由；响应表的「必填 / 可选」说的才是**你**要不要返回。

### POST {base}/wallet/balance（上线必须）

查玩家当前余额。游戏启动前我们会先调它做预检（所有厂商统一），游戏内刷新余额也走它。**只读、无幂等键、无副作用**——五个端点里最简单的一个。

#### 请求字段

| 字段 | 类型 | 出现 | 说明 | 示例 |
|---|---|---|---|---|
| player_id | string | 总是有 | 你系统里的玩家 ID——就是你在 sessions/create 时传给我们的那个，原样带回 | "wang-123" |
| currency | string | 总是有 | 查哪个币种的钱包。法币 = ISO 4217 三字母；加密货币 3–4 位 | "BRL" |
| game_code | string | 不一定有 | 有游戏上下文时给（`<provider>:<游戏段>`，不透明键）。有的厂商查余额不带游戏 | "pg:fortune-tiger" |
| session_id | string | 不一定有 | 我们的会话 ID（ses_）。排障时报给支持用，**不需要校验** | "ses_01H…" |
| session_payload | string | 不一定有 | 你启动游戏时塞的透传串，原样奉还（用于零查表路由回你的内部上下文） | "wsess-8f2\|ios" |

#### 你要返回什么

| 字段 | 必填 | 说明 |
|---|---|---|
| status | 必填 | "ok"；玩家不存在/币种不符可 "declined" |
| balance | 必填 | 当前余额，字符串十进制 |

### POST {base}/wallet/debit（上线必须）

从玩家钱包**扣款**。方向永远是扣、金额永远非负——你不用判断正负号。业务上不能扣（余额不足等）就返回 declined，这是合法答复不是错误，我们收到就终止、绝不重试。

#### 请求字段（完整）

| 字段 | 类型 | 出现 | 说明 | 示例 |
|---|---|---|---|---|
| transaction_id | string | 总是有 | **幂等键**。全局唯一（txn_ 前缀）。见过 → 答 duplicate:true，别记账 | "txn_01H…" |
| player_id | string | 总是有 | 你系统里的玩家 ID（sessions/create 时你传的，原样带回） | "wang-123" |
| currency | string | 总是有 | 交易币种（与本次会话/回合的币种一致）。玩家可支持多币种；此币种不在该玩家支持范围内 → declined currency_mismatch | "BRL" |
| amount | string | 总是有 | 扣多少。字符串十进制、非负 | "10.00" |
| source | string | 总是有 | 这笔扣款是什么：`bet` 游戏下注 · `bonus_buy` 功能购买 · `tip` 真人桌小费 · `entry_fee` 锦标赛报名费 · `adjustment` 事后更正。**没见过的值也照扣**（我们上新类型你零改动） | "bet" |
| round | object | 不一定有 | `{id, sequence, finished}`——回合 ID、回合内序号（1,2,3…可用于断言顺序）、收口标志。小费等无回合事件不带 | {"id":"rnd_…","sequence":1,"finished":false} |
| campaign_id | string | 不一定有 | 这笔钱源于你创建的活动时出现（cmp_），到账即可归因 | "cmp_01H…" |
| game_code | string | 不一定有 | 游戏标识（`<provider>:<游戏段>`，不透明键，原样入账），记账/风控归因用；有游戏上下文时给 | "pg:fortune-tiger" |
| provider | string | 不一定有 | 厂商代号（厂商族码，来自本面 providers/list 的 code；game_code 的前半段单独再给一份，省你拆字符串） | "pg" |
| provider_product | string | 不一定有 | 厂商产品维度键回显（多产品线厂商才出现，如 db 的 `card`）——按产品线记账/报表用，零处理义务 | "card" |
| session_id | string | 不一定有 | 我们的会话 ID（ses_）。排障时报给支持用，零处理义务 | "ses_01H…" |
| session_payload | string | 不一定有 | 你启动游戏时塞的透传串，原样奉还（零查表路由回你的内部上下文），零处理义务 | "wsess-8f2\|ios" |
| occurred_at | string | 总是有 | 事件发生时刻（RFC 3339），直接可入账本 | "2026-07-03T12:00:00.000Z" |

#### 你要返回什么

| 字段 | 必填 | 说明 |
|---|---|---|
| status | 必填 | "ok" 记账成功 · "declined" 业务拒绝（余额不足等）· "error" 验签失败 |
| balance | 必填 | 扣款后余额；declined 为 insufficient_funds / player_blocked / bet_limit_exceeded 时也必带 |
| reason | declined 时必填 | 拒绝原因（见通用约定的 reason 表） |
| operator_transaction_id | 可选 | 你侧账目 ID 回执，对账互查用 |
| duplicate | 重复时 | 幂等命中时为 true（返回当前余额、不记账） |

### POST {base}/wallet/credit（上线必须）

向玩家钱包**入账**。游戏派彩、免费旋转结算、活动奖金、彩池、零额收口——全走这一个端点，`source` 告诉你是哪种钱。**入账逻辑只写一次**，以后我们上任何新资金类型，对你都只是一个新的 source 字符串。

> **铁律 ① · 认证套件必测**：**credit 永远不许 declined**（唯二例外 player_not_found / currency_mismatch——账本无法定位、拒收合理，PlayRouter 收到即进对账队列 + 双方告警，不重试不丢弃；用户裁决 2026-07-08）。玩家赢的钱必须到账——哪怕他此刻不存在余额异常、哪怕入账后你想拒。无法处理（你内部故障）返回 5xx，我们自动重试直到送达。

#### 请求字段（完整）

| 字段 | 类型 | 出现 | 说明 | 示例 |
|---|---|---|---|---|
| transaction_id | string | 总是有 | **幂等键**。全局唯一（txn_ 前缀）。见过 → 答 duplicate:true，别记账 | "txn_01H…" |
| player_id | string | 总是有 | 你系统里的玩家 ID（sessions/create 时你传的，原样带回） | "wang-123" |
| currency | string | 总是有 | 交易币种（与本次会话/回合一致）。玩家可支持多币种；不在其支持范围内 → declined currency_mismatch | "BRL" |
| amount | string | 总是有 | 入多少。字符串十进制、非负（source=round_finish 时为零，如 "0.00"） | "24.50" |
| source | string | 总是有 | `game_win` 游戏派彩 · `freespin_payout` 免费旋转结算 · `freechip_payout` 免费筹码结算 · `bonus_buy_payout` 功能购买派彩 · `promo_win` 活动奖金 · `jackpot` 彩池 · `adjustment` 事后更正 · `round_finish` 零额回合收口。**未知值照收** | "game_win" |
| related_transaction_id | string | 不一定有 | 这笔派彩对应的原下注（txn_）。**出现则保证那笔已成功送达过你**——放心用它做注赢配对，永不悬空 | "txn_01H…" |
| campaign_id | string | 不一定有 | 源于你的活动时出现——两个活动并行时，这笔派彩算哪个活动的成本一目了然 | "cmp_01H…" |
| round | object | 不一定有 | `{id, sequence, finished}`。活动奖金等无回合事件不带；round_finish 收口时 finished 恒为 true | {"id":"rnd_…","sequence":2,"finished":true} |
| game_code | string | 不一定有 | 游戏标识（`<provider>:<游戏段>`，不透明键，原样入账），有游戏上下文时给 | "pg:fortune-tiger" |
| provider | string | 不一定有 | 厂商代号（厂商族码，来自本面 providers/list 的 code） | "pg" |
| provider_product | string | 不一定有 | 厂商产品维度键回显（多产品线厂商才出现，如 db 的 `card`）——按产品线记账/报表用，零处理义务 | "card" |
| session_id | string | 不一定有 | 我们的会话 ID（ses_），排障用，零处理义务。异步派彩（免费旋转结算等）可能不带——会话早结束了 | "ses_01H…" |
| session_payload | string | 不一定有 | 你的透传串原样奉还，零处理义务 | "wsess-8f2\|ios" |
| occurred_at | string | 总是有 | 事件发生时刻（RFC 3339） | "2026-07-03T12:00:00.000Z" |

#### 你要返回什么

| 字段 | 必填 | 说明 |
|---|---|---|
| status | 必填 | "ok" 或 "error"（验签失败）。**credit 没有 declined**（铁律①）——内部故障返回 5xx 让我们重试 |
| balance | 必填 | 入账后余额（round_finish 时余额不变，照常返回） |
| operator_transaction_id | 可选 | 你侧账目 ID 回执 |
| duplicate | 重复时 | 幂等命中时为 true |

> **source = "round_finish" 是什么**：回合结束但没有钱要动时（比如厂商的结束信号迟到），我们发一笔金额为零（如 `amount:"0.00"`）的 credit 来收口。你按普通 credit 处理即可：入账 0（余额不变）、读 `round.finished:true` 关闭回合。**收口保证**：每个回合最终恰好一次 finished:true——放心在它上面挂"清理未完成回合表"这类逻辑。唯一例外语义：收口后仍可能到达迟到的 rollback（更正流，不属游戏流）——冲正不改变收口状态，照常处理。

### POST {base}/wallet/settle（上线必须）

一次调用完成"扣注 + 派彩"。这是**少数厂商的形态**：多数厂商把下注和派彩分成两次回调（走上面的 debit + credit），少数厂商把一局输赢合并成单笔结算发来——那部分流量走这里。五个端点认证时都要实现，但你实际收到多少 settle 取决于开通了哪些厂商。你在**同一个 DB 事务**里：扣 `bet`、加 `win`、返回最新余额。扣款部分不足 → 整体 declined，没有"扣了一半"这种状态。

#### 请求字段（完整）

| 字段 | 类型 | 出现 | 说明 | 示例 |
|---|---|---|---|---|
| transaction_id | string | 总是有 | **幂等键**。整局一笔、一个键 | "txn_01H…" |
| player_id | string | 总是有 | 你系统里的玩家 ID | "wang-123" |
| currency | string | 总是有 | 交易币种（与本次会话/回合一致）。玩家可支持多币种；不在其支持范围内 → declined currency_mismatch | "BRL" |
| bet | string | 总是有 | 本局下注额（要扣的） | "10.00" |
| win | string | 总是有 | 本局派彩额（要加的）。**输了的回合照常发**：win:"0.00"，此时这笔的意义就是记下注 + 收口 | "24.50" / "0.00" |
| source | string | 总是有 | 恒为 "game"（合笔结算只服务游戏回合） | "game" |
| round | object | 总是有 | `{id, sequence, finished}`。合笔结算通常即回合终局，finished 常为 true | {"id":"rnd_…","sequence":1,"finished":true} |
| game_code | string | 不一定有 | 游戏标识（`<provider>:<游戏段>`，不透明键，原样入账） | "pg:fortune-tiger" |
| provider | string | 不一定有 | 厂商代号（厂商族码，来自本面 providers/list 的 code） | "pg" |
| provider_product | string | 不一定有 | 厂商产品维度键回显（多产品线厂商才出现，如 db 的 `card`）——按产品线记账/报表用，零处理义务 | "card" |
| session_id | string | 不一定有 | 我们的会话 ID（ses_），零处理义务 | "ses_01H…" |
| session_payload | string | 不一定有 | 你的透传串原样奉还，零处理义务 | "wsess-8f2\|ios" |
| occurred_at | string | 总是有 | 事件发生时刻（RFC 3339） | "2026-07-03T12:00:00.000Z" |

#### 你要返回什么

| 字段 | 必填 | 说明 |
|---|---|---|
| status | 必填 | "ok" · "declined"（**扣款部分不足 → 整体拒绝**，没有"扣一半"）· "error" |
| balance | 必填 | 结算后余额（= 原余额 − bet + win）；insufficient_funds 拒绝时也必带 |
| reason | declined 时必填 | 拒绝原因 |
| operator_transaction_id | 可选 | 你侧账目 ID 回执 |
| duplicate | 重复时 | 幂等命中时为 true |

### POST {base}/wallet/rollback（上线必须）

撤销一笔早前交易的余额影响：**原来是扣的就退回去，原来是入的就收回来**。厂商超时取消、真人桌判定作废时会发它。

> **铁律 ① 的另一半 · 认证套件必测**：**rollback 不许 declined，余额可以为负。**典型场景：玩家赢 100 → 提现走了 → 该局被判作废 → 冲正要收回 100，余额只剩 5 → 记成 `-95.00`，返回 ok。负数不是系统错误，是在诚实记录玩家欠的钱（下次入金抵扣，你自行风控追偿）。若你的钱包表不支持负数：扣到 0 + 差额挂应收账，同样合规。

> **两个让你安心的保证**：① `original_transaction_id` **必然是你成功处理过的交易**——不存在"冲正一笔没见过的交易"这种事（厂商的乱序冲正我们吸收）。② rollback 之后，那笔原交易绝不会再投递给你。

#### 请求字段（完整）

| 字段 | 类型 | 出现 | 说明 | 示例 |
|---|---|---|---|---|
| transaction_id | string | 总是有 | **幂等键**——注意这是本次冲正**自己的**交易 ID（冲正也是一笔交易），别和下一行搞混 | "txn_01J…" |
| original_transaction_id | string | 总是有 | 要撤销**哪笔**。查你的账本，按那笔的方向反向操作（原为扣则退、原为入则收回）。保证是你成功处理过的交易 | "txn_01H…" |
| player_id | string | 总是有 | 你系统里的玩家 ID | "wang-123" |
| currency | string | 总是有 | 交易币种 | "BRL" |
| round | object | 不一定有 | `{id, sequence, finished}`——回合可以以冲正收口（finished:true 随冲正携带）。小费冲正等无回合事件不带 | {"id":"rnd_…","sequence":3,"finished":true} |
| game_code | string | 不一定有 | 游戏标识（`<provider>:<游戏段>`，不透明键） | "pg:fortune-tiger" |
| provider | string | 不一定有 | 厂商代号（厂商族码，来自本面 providers/list 的 code） | "pg" |
| provider_product | string | 不一定有 | 厂商产品维度键回显（多产品线厂商才出现，如 db 的 `card`）——按产品线记账/报表用，零处理义务 | "card" |
| session_id | string | 不一定有 | 我们的会话 ID，零处理义务（冲正常发生在会话结束后，可能不带） | "ses_01H…" |
| session_payload | string | 不一定有 | 你的透传串原样奉还，零处理义务 | "wsess-8f2\|ios" |
| occurred_at | string | 总是有 | 冲正事件时刻（RFC 3339） | "2026-07-03T12:05:00.000Z" |

#### 你要返回什么

| 字段 | 必填 | 说明 |
|---|---|---|
| status | 必填 | "ok" 或 "error"（验签失败）。**rollback 没有 declined**（铁律①）——余额不够收回也要执行 |
| balance | 必填 | 冲正后余额，**可以为负**（如 "-95.00"——钱包回调里唯一允许负号的字段） |
| operator_transaction_id | 可选 | 你侧账目 ID 回执 |
| duplicate | 重复时 | 幂等命中时为 true |

## 错误与重试（我们侧的行为，写给你参考）

| 你返回 | 我们做什么 |
|---|---|
| 200 + ok / declined | 终态。declined 绝不重试 |
| 200 + error(invalid_signature) | 配置故障桶：告警 + 冻结投递（修复确认后恢复），不重试轰炸 |
| 任何 4xx（429 除外——429 按 5xx 进重试时间表） | 同上（按类别解读，401 写成 403 也不出事故） |
| 3xx（跳转） | 同上：我们不跟随跳转，按配置故障处理（告警 + 冻结投递，`delivery_status` 为 `frozen`）；把注册地址改成最终地址、确认后恢复 |
| 5xx / 超时（10s） | 重试时间表：5s → 1m → 5m → 1h → 6h → 12h → 24h（共 8 次）。同回合后续事件排队等待、不跳队。全部失败 → 对账队列 + 双方告警 + `delivery_status:"failed"` 可查 |

## 附录 · 速查

写代码时贴在手边的三张表：ID 前缀、完整错误码、方向 B 的 declined reason。逐字段权威以规范 md / yaml 为准。

### ID 前缀约定

| 前缀 | 含义 |
|---|---|
| txn_ | 资金交易（方向 B 回调的幂等键，也是 transactions/get 的查询键） |
| rnd_ | 游戏回合 |
| rec_ | 游戏记录（records/list 去重主键、records/get 查询键） |
| ses_ | 游戏会话（sessions/create 返回） |
| cmp_ | 活动（campaigns/create 返回，cancel 的唯一引用键） |
| req_ | 请求追踪（错误体 / 响应头 X-Request-Id） |

### 完整错误码目录 · 方向 A（30 个）

| 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 | 产品线未开通——账号未开通 Seamless 面时，`/v1/seamless/…` 前缀下的**全部**端点（含 sessions / games / providers / currencies）均返本码；已开通的 transfer 面前缀不受影响（契约 v2：面由路径前缀承载） |
| 403 | ip_not_allowed | 来源 IP 不在你为该环境配置的白名单内（可选功能；名单为空不校验；无法判定来源 IP 时同码）——把服务器全部出口 IP（双栈含 v4 与 v6）加入 console「API settings」名单，约 30 秒生效 |
| 404 | not_found | 路径不存在 |
| 404 | game_not_found | game_code 不存在 |
| 404 | campaign_not_found | 引用的活动不存在 |
| 404 | transaction_not_found | 引用的交易不存在 |
| 404 | record_not_found | records/get 查无此记录（含不属于你的记录） |
| 404 | session_not_found | 引用的会话不存在 |
| 409 | idempotency_key_reused | 同 key 不同 body（绝不执行，修 bug） |
| 409 | request_in_progress | 同 key 同 body、首请求在途（瞬态）——按 `Retry-After` 秒数用同 key 重试即命中重放 |
| 413 | payload_too_large | 请求 body 超过大小上限——报文瘦身后重试 |
| 422 | validation_failed | 字段缺失 / 类型错 / 超限（fields 逐字段明细） |
| 422 | currency_not_supported | 平台 / 游戏 / 厂商三级币种不支持，一码讲完。实例：saba 实例只登记 USD，real 模式 `saba:lobby` + CNY → currency_not_supported（建户 / 打厂商之前拒）；demo 不校验，展示币种由厂商侧静默降级。**平台侧暂时不可用**时 real 启动也返此码，`fields.currency` = `temporarily unavailable`（固定值，可按它程序判断）——此时**不要换币种**，稍后重试同一请求即可 |
| 422 | currency_mismatch | 该玩家在这个厂商（多产品线厂商按产品线）已使用其他币种——同一厂商内一个 player_id 只能使用一个币种。message 点名该币种（"Player currency at this provider is CNY."），按它开局，或按币种拆 player_id。仅 sessions/create（real）返回 |
| 422 | game_not_available | 游戏存在但当前不可用（地域 / 下架 / 未到 released_at），或厂商拒绝该玩家进入（如玩家账号被停用）——重试同一请求不会成功。**平台侧暂时不可用**时 sessions/create 也返此码，`fields.game_code` = `temporarily unavailable`（固定值，可按它程序判断）——游戏没有下架，不要从大厅撤下，稍后重试同一请求即可 |
| 422 | demo_not_available | 游戏不支持试玩 |
| 422 | provider_options_invalid | 厂商参数不合 campaigns/options 的 schema |
| 422 | campaign_state_invalid | 活动状态机不允许（活动已 ended，或厂商没有撤回能力时取消 started 活动） |
| 429 | rate_limited | 超配额，按 Retry-After 重试 |
| 500 | internal_error | 我们的故障（带 request_id 报障） |
| 502 | provider_error | 上游厂商故障（**仅瞬态**——上游的确定性拒绝会映射为对应 4xx 语义码，不会以 502 出现；收到 502 放心重试） |
| 503 | service_unavailable | 维护 / 过载 |
| 504 | provider_timeout | 预留码，**目前不会出现**；如遇到，按 5xx 处理（变更类同 idempotency_key 重试） |

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

### 200 里唯一的业务失败：campaigns/create 名单逐项 results（3 个）

整批 HTTP 2xx，每个玩家的结果在 `results[].status`（ok | failed）逐项给出——这是逐项答复不是错误，名单内其余成员照常生效。create 首次应答只反映受理校验，厂商侧逐玩家结果在投放后经 campaigns/get 给出。

| results[].code | 语义 |
|---|---|
| player_id_invalid | player_id 格式不合法（受理校验；预留码——目前名单含不合法 ID 时整单回 422 validation_failed） |
| campaign_player_limit_reached | 活动人数已达厂商上限，本项未加入 |
| provider_rejected | 厂商拒绝向该玩家发放（投放后才知道，经 campaigns/get 查看），本项未发放 |

### 方向 B · declined reason 枚举（5 个）

全部通过 `200 + {"status":"declined","reason":…}` 返回——业务答复不是错误；我们收到即终态，绝不重试。

| reason | 适用回调 | 语义 |
|---|---|---|
| insufficient_funds | debit / settle | 余额不足以扣款（须附 balance） |
| player_not_found | 全部 | 玩家在你系统中不存在（balance 可省略） |
| player_blocked | debit / settle | 玩家被禁投 / 自我排除 / 游戏对其禁用（须附 balance） |
| bet_limit_exceeded | debit / settle | 责任博彩限额（玩家自设或监管强制）（须附 balance） |
| currency_mismatch | 全部 | 交易币种不在该玩家支持的币种范围内（玩家可多币种；balance 可省略） |

> **铁律回顾**：**credit / rollback 永不 declined**（余额可负也必须收下；唯二例外 player_not_found / currency_mismatch——账本无法定位，我方进对账处理）；**禁止以会话过期为由拒钱**；`duplicate: true` 是成功答复不是 decline；未知 reason 值我们照收并原样记录（前向兼容）。`status` 全枚举：ok（含 duplicate 重放）· declined（业务拒绝，终态）· error（非业务处理失败，非终态——进配置故障桶：告警 + 冻结投递）。

## 上线前，认证套件会测什么

**幂等与签名**：同 transaction_id 重复投递 → duplicate:true 且余额不变 · 篡改 body 验签拒绝 · 时间戳出窗拒绝 · 双 key 轮换期两把都能验

**铁律三条**：credit 在余额异常时照常入账 · rollback 打出负余额且返回 ok · 未知 source 值照收 · 会话过期后资金回调照常处理

**回合与收口**：settle win 为零（"0.00"）输局照常记账 · 零额 round_finish 正确关闭回合 · sequence 单调性 · finished 恰好一次 · 收口后迟到冲正照常处理

**业务拒绝**：余额不足 declined + 必附 balance · declined 后我们不重试 · 5xx 后同笔重试只记一次账

**方向 A · 核心卷**：providers/list · currencies 目录拉取 · games/list 增量与翻页正确 · sessions/create 三连降级（locale / demo 币种 / launch_mode）· 同一厂商换币种开局返回 currency_mismatch 并按 message 处理 · 429 按 Retry-After 退避 · 5xx 后同 idempotency_key 重试

**方向 A · 活动卷（启用活动模块才考）**：create 幂等重放答复一致 · 名单逐项 results 处理（failed 不连坐）· 422 厂商约束（时长 / provider_options schema）· 取消回执处理（started 取消即回 `cancelled`、再次取消返回同一回执）· create 响应持久化

## FAQ

**我的钱包表不允许负数怎么办？**
扣到 0，差额记进对该玩家的应收账（debt ledger），下次入金先抵扣——协议要求的是 effect-once 记账并返回 ok，内部怎么表示是你的自由。参考实现里带这个模式。

**会话过期了，资金回调要拒吗？**
**绝对不要。**合法性只由签名决定。会话真伪我们在厂商边界已经验过了；异步派彩（免费旋转结算等）天然发生在会话死后，按会话拒钱会把玩家赢的钱卡在协议外。

**收到不认识的 source 值？**
照常入账/扣款，把值原样记进账本即可。这是前向兼容机制：我们上新资金类型时你零代码改动。

**为什么响应恒 200，不用 HTTP 语义？**
HTTP 码在这套协议里只回答"要不要重试"：2xx=终态、5xx=重试、4xx=配置故障（429 除外——429 按 5xx 进重试时间表）。业务结果（ok/declined）是应用层语义，放 body。这样你写错分支也不会触发意外重试。

**duplicate:true 需要返回当初的原响应吗？**
不需要。返回**当前**余额 + duplicate:true 即可，你只需存"处理过的 ID"，不用持久化响应体。

**时钟不准会怎样？**
签名头的 t 有 ±300 秒容忍窗。服务器 NTP 正常就没问题；若持续验签失败，先查时钟漂移。

**round.sequence 我必须处理吗？**
不必须。它是给你的可选防御工具：断言"同回合序号必须递增"，若告警说明出了怪事。忽略它，账照样是对的。

**注单（records）和交易流水（transactions）有什么区别，对账用哪个？**
资金对账用 `transactions/list`——它是逐笔资金流水，和你收到的钱包回调一一对应。`records/list` 是按局汇总的游戏记录，给玩家游戏记录、客服查询、按局统计用；它不含活动 / 奖励类入账，也只计已入账的流水，所以**不能当资金对账依据**。两者用 `round_id` + `player_id` 关联。

**campaigns/create 的响应丢了，活动找不回来了？**
7 天内用同一个 idempotency_key 重发同一请求——拿回的是当初的原响应（带 `Idempotency-Replayed: true`），campaign_id 就在里面。超过 7 天用 `campaigns/list` 按名称/时间窗定位活动、再用 `campaigns/get` 取回全量（含逐玩家 results）。「持久化 create 响应」仍是接入要求，认证套件会测——查询面是兜底，不是替代。

**为什么没有 players/add 这类端点？**
最小面设计：名单随 create 内嵌（≤1000/次，一次调用发千人），中途追加 = 另建活动。活动查询面（list / get）是这一原则下的实证演进案例——v1 初版刻意不设，实需出现后按纯加法回补（2026-07-09），未发生破坏性变更。

**launch_mode:"html" 什么时候用？**
你的市场存在域名封锁风险时。默认 url 形态下游戏域名是第三方的，封锁单点不在你手里；html 直出让游戏从你自己的域名伺服（厂商支持时），封锁面收敛为你自己的域名。厂商不支持时自动降级返 url，不报错。游戏能力看 games/list 的 `launch_modes`。

**方向 A 验签失败是 401，方向 B 为什么让我回 200？**
角色不同。方向 A 你是调用方，我们用真 HTTP 码告诉你接下来该干什么（401 = 修配置）。方向 B 你是被调方，恒 200 是钱包回调的行业惯例——而且我们按类别解读你的答复：就算你回了 4xx（429 除外——429 按 5xx 进重试时间表）也只会进配置故障桶（告警 + 冻结投递），不会被重试轰炸。

## 下载与资源

- 方向 A · OpenAPI 3.1：`playrouter-seamless-wallet-api-v1.openapi.yaml`
- 方向 B · OpenAPI 3.1：`playrouter-seamless-wallet-callbacks-v1.openapi.yaml`
- 本页 Markdown 版 · for LLM：`playrouter-seamless-docs.llm.md`（即本文件）

两个 OpenAPI 文件可直接导入 Postman / Insomnia 生成请求集合；方向 A 可生成客户端 SDK 桩，方向 B 可生成服务端桩代码。Markdown 版与文档站内容一致，专供投喂 AI 编码助手。

---

PlayRouter Seamless Wallet 运营商对接文档 · v1 · 2026-07-05 · 方向 A（15 端点）+ 方向 B（5 回调）· HTML 版 Playground 为纯前端模拟，不产生网络请求 · 字段级权威：OpenAPI yaml ×2
