openapi: 3.1.0
info:
  title: PlayRouter Transfer API（转账钱包产品）
  version: 1.1.1
  summary: 转账钱包运营商调用 PlayRouter 的 11 个端点：启动游戏、目录、钱包划拨、余额、游戏记录对账。零回调。
  description: |
    **独立产品，自包含文档**——本文件是转账钱包产品的完整 API 面，你不需要阅读 seamless 产品的任何文档。
    与 seamless 产品的关系只有一条：目录/会话/币种 5 个端点（sessions/create·terminate、games/list、
    providers/list、currencies）的请求/响应字段 **逐字相同**、路径只差钱包模式前缀（见下节）——它们的行为在两种钱包模式下本无区别，
    同一行为不该有两个名字；将来升级 seamless 时这 5 个端点的对接代码只需换前缀，**密钥也是同一把**
    （密钥账号级通用）：一把 pk_/sk_ 对两条产品线同时有效。你能调用哪些端点取决于账号的产品开通
    状态，与密钥无关。

    ## 钱包模式面进路径（商户契约 v2）
    路径第二段是**钱包模式面**：本文件（转账钱包）全部端点为 `/v1/transfer/<域>/<动作>`，
    seamless 钱包产品为 `/v1/seamless/<域>/<动作>`（单段路径同理：`/v1/transfer/currencies` / `/v1/seamless/currencies`）。
    账号开通哪个面才能调用哪个前缀——调用未开通面的前缀返回 **403 `product_not_enabled`**。
    两面共有的 5 个目录/会话端点字段**逐字相同**，只是前缀不同。

    ## 钱包拓扑（本产品的第一原理）
    **纯转发，逐厂商钱包**：玩家玩哪家厂商，钱就划到哪家厂商侧的游戏钱包；PlayRouter 只转发划拨指令，
    **不持有玩家资金**（不是托管方）。因此：
    - 划拨（transfers/create）与查余额（wallet/balance）都必须指定 `provider`——钱在谁手里，接口如实暴露；
    - 玩家跨厂商换游戏 = 先从 A 厂商下分、再向 B 厂商上分（两笔独立划拨）；
    - **目录边界**：games/list 只含支持转账形态的厂商；
      仅支持 seamless 形态的厂商在本产品不可见——纯转发拓扑下没有任何一方持有它们要调用的钱包。

    ## 单币种钱包
    每个 `player_id` 在首次划拨/启动时确定币种，此后不可更改；多币种玩家请拆成多个 player_id
    （如 wang-123-brl / wang-123-usd）。请求中的 `currency` 是一致性防呆：与钱包币种不符 → 422 `currency_mismatch`。

    ## 没有 withdraw_all（有意不做）
    "取回全部"只有在钱包静止时才是良定义的——没有任何厂商保证跨局静止（玩家关窗 ≠ 局结束）。替代方案：
    wallet/balance 的 `transferable` 预览（有此口径的厂商透传）+ 文档站的"取回全部"参考流程
    （查 → 按 transferable 取 → 失败重查再取，统一 reason 枚举驱动分支）。

    ## 接入分层（必须面 = 7 + 0 回调）
    | 层 | 端点 |
    |---|---|
    | **核心（上线必须）** | sessions/create · games/list · providers/list · currencies · transfers/create · wallet/balance · records/list |
    | 运营（强烈建议，可后补） | transfers/get（pending 收尾 + 排障）· records/get（客诉单查）· wallet/balances（厂商内全产品/场馆余额归集，可选） |
    | 会话辅助（可选） | sessions/terminate |

    转账运营商第一天的心智负担：调 7 个端点，**实现 0 个回调**——游戏期间 PlayRouter 不会调用你的任何接口；
    玩家输赢经 records/list 拉取（这是你知道输赢的唯一渠道，故属必须）。

    ## 全局协议（与 seamless 产品同一套 §1，逐字生效）
    - **一律 POST + `application/json; charset=utf-8`**，无 GET；所有端点带 `/v1/transfer` 前缀（见「钱包模式面进路径」）；
      演进只做加法（新增可选字段/枚举值），接收方必须忽略未知字段（前向兼容）。
    - **签名**：双头 HMAC-SHA256（`X-Playrouter-Key-Id` + `X-Playrouter-Signature`，见各端点 parameters）；
      签名串 = `"{t}" + "." + "{raw_body}"`，timestamp 容忍窗 ±300 秒；raw body 逐字节签名。
      验签失败 → **401**（语义正确且唯一）。每环境最多 2 把 active key 并行轮换。
    - **幂等**：变更类端点（本产品仅 transfers/create）body 必填 `idempotency_key`
      （1–128 字符 `[A-Za-z0-9_.-]`，推荐确定性派生如 `tf-{内部订单ID}`，**严禁每次重试新生成**）。
      命中重放返回**原响应** + 响应头 `Idempotency-Replayed: true`；同 key 不同 body → 409；保留 7 天。
      **拿不到响应就同 key 重发，恒安全（在同一区内）**——没有"哪些错误态可重发"的清单要背，查单不是重试的前置仪式。
    - **错误模型**：HTTP 码管重试动作，body 恒为固定形状 ErrorResponse（语义字符串 code，不用数字）。
      调用方只需 4 个分支：2xx 用 body / 429 按 Retry-After 重试 / 5xx 或超时同 idempotency_key 重试 / 其余 4xx 终态记日志等人修。
      **200 永远是成功语义**——划拨的业务结局（余额不足、局内锁）不是错误，是 200 + `status:"failed"` + reason。
    - **限流**：标准响应头 `RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset`；超限 429 + `Retry-After`。
    - **金额**一律字符串十进制（请求与响应同型，无 K 货币单位换算——大面值币种直接按真实面值传），
      精度按 /v1/transfer/currencies 的每币种 decimals；**时间**一律 RFC 3339 UTC 毫秒；字段 snake_case。
    - **不托管玩家资料**：全 API 的玩家身份面只有不透明的 `player_id`，不收姓名/邮箱/生日/国籍等 PII
      （无需你在厂商侧建户）。
  contact:
    name: PlayRouter Docs
    url: https://docs.playrouter.io
servers:
  - url: "https://eu.api.playrouter.io"
    description: 生产 · EU（法兰克福）——三服全开放，固定用离钱包后端最近的一个；用哪个，归属就在哪个。改用别的区不需要新账号或新密钥；数据按区存放、区与区不互通——旧区的划拨与注单记录留在旧区，照常可查。**玩家已在某个区上分进游戏的余额，请在同一个区查询和取回**，取回后再到新区上分。**拿不到响应要重试时，也请留在同一个区**：同一个 `idempotency_key` 只在同一区内去重，换到别的区重发会被当成一笔新请求再执行一次。
  - url: "https://sea.api.playrouter.io"
    description: 生产 · SEA（新加坡）
  - url: "https://latam.api.playrouter.io"
    description: 生产 · LATAM（圣保罗）
  - url: "https://api.sandbox.playrouter.io"
    description: 沙箱（单区，数据完全隔离；含边界模拟器与认证测试套件；无归属概念、不承诺延迟 SLA）

tags:
  - name: 核心（上线必须）
  - name: 钱包划拨（上线必须）
  - name: 对账（上线必须）
  - name: 运营（强烈建议）
  - name: 会话辅助（可选）

paths:
  /v1/transfer/sessions/create:
    post:
      operationId: sessionsCreate
      tags: ["核心（上线必须）"]
      summary: 【上线必须】启动游戏（真钱/试玩合一）
      description: |
        与 seamless 产品同一端点定义。转账模式注意：启动前请确认玩家在该游戏所属厂商的游戏钱包有余额
        （transfers/create 上分）——余额为 0 也能进游戏，但玩家会在游戏内看到 0 余额。

        **启动形态三条契约**：①响应 `url` 恒在（HTML 直出型厂商由 PlayRouter 托管 launcher 包装成 URL）；
        ②请求可选 `launch_mode: "html"`——厂商支持 HTML 直出时响应另给 `html`，
        运营商从自有域名伺服，抗封锁价值完整保留；③声明降级——厂商只有 URL 形态时返 `url`、`html` 缺省、不报错。
        游戏是否支持 HTML 直出，看 games/list 对象的 `launch_modes`。

        **demo 模式的 currency = 期望展示币种**：厂商支持试玩币种则用之，不支持则以厂商默认币种（通常 EUR）启动，
        静默降级不报错。real 模式的 currency 是钱包币种（单币种钱包，见 info），精确匹配、永不降级。
        locale 同哲学：不支持的值静默降级 en。

        **条件必填**（schema 层可选，由 422 `validation_failed` 执行）：`mode:"real"` 时 `player_id` 与 `ip` 必填；`mode:"demo"` 时两者可省略。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SessionsCreateRequest"
      responses:
        "200":
          description: 会话已创建；`url` 恒在，`html` 仅在 launch_mode:"html" 且厂商支持时出现。
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionsCreateResponse"
        "400": { $ref: "#/components/responses/MalformedRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /v1/transfer/sessions/terminate:
    post:
      operationId: sessionsTerminate
      tags: ["会话辅助（可选）"]
      summary: 【可选】终止会话（best-effort 踢出）
      description: |
        **尽力而为的踢出扇出，不是强制执行**——能否踢出、踢出后玩家能否重登，效果因厂商而异。
        转账模式的 RG/自我排除**硬闸门**：拒绝该玩家后续 deposit（划拨在你手里）+ 厂商侧封禁通道承接；
        terminate 是体验层（把人请出桌）。注意：踢出不会使游戏提前结算，
        局内锁定的余额仍需等局结束才可下分（见 wallet/balance 的 transferable）。

        请求给 `player_id`（终止该玩家全部活跃会话）或 `session_id`（指名终止）二选一。
        非资金、天然幂等，不带 idempotency_key。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: player_id / session_id 二选一必填。
              oneOf:
                - required: [player_id]
                - required: [session_id]
              properties:
                player_id:
                  type: string
                  maxLength: 64
                  description: 终止该玩家的全部活跃会话。
                  examples: ["wang-123"]
                session_id:
                  type: string
                  description: 指名终止单个会话（ses_ 前缀）。
                  examples: ["ses_01H8XGJWBW"]
      responses:
        "200":
          description: 已受理踢出扇出（best-effort 语义——200 表示扇出已执行，不担保厂商侧踢出结果）。
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    description: v1 无字段（预留对象位，演进按加法补充逐厂商扇出结果）。
        "400": { $ref: "#/components/responses/MalformedRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /v1/transfer/games/list:
    post:
      operationId: gamesList
      tags: ["核心（上线必须）"]
      summary: 【上线必须】游戏目录（cursor 分页 + 增量同步）
      description: |
        与 seamless 产品同一端点定义。对象数组 + cursor 分页 + `updated_after` 增量同步。
        元数据齐备：图、语言、币种、面值、features、rtp、启动形态、状态一个对象拿全。

        **目录边界（转账产品专属）**：只含转账形态厂商——仅有 seamless 形态的厂商在本产品不可见
        （纯转发拓扑下没有任何一方持有它们要调用的钱包）。

        首次同步：响应头 `X-Full-Dump-Url` 给每日全量快照（CDN URL），万级目录不用翻页；链接下载失败时，改用不带 `updated_after` 的分页全量。
        日常：`updated_after` 只拉变更（厂商下架也是一次 update，`status:"recalled"`；从目录消失的游戏不在增量里，见 Game.status）。
        合规不用自算：不可用的游戏启动时返回 `game_not_available`；平台停用的厂商（产品线）的游戏启动时返回 403 `provider_not_enabled`
        （游戏本身已下架或未上架的仍返回 `game_not_available`）。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GamesListRequest"
      responses:
        "200":
          description: 游戏对象分页。
          headers:
            X-Full-Dump-Url:
              description: 每日全量快照的 CDN URL（首次同步用）。
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GamesListResponse"
        "400": { $ref: "#/components/responses/MalformedRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /v1/transfer/providers/list:
    post:
      operationId: providersList
      tags: ["核心（上线必须）"]
      summary: 【上线必须】厂商目录（展示名 / 厂商级状态）
      description: 与 seamless 产品同一端点定义。机器码 + 展示名 + 品类 + 币种 + 状态。转账模式下 provider 还是划拨与余额的定位键（钱在哪家）。用途：后台厂商开关页 / lobby 厂商分区展示名 / 厂商级维护状态；低频目录，日缓存即可。请求为空对象 `{}`。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: 空对象（租户由 Key-Id 解析，无需任何字段）。
      responses:
        "200":
          description: 厂商对象数组（仅转账形态厂商）。
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Provider"
        "400": { $ref: "#/components/responses/MalformedRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /v1/transfer/currencies:
    post:
      operationId: currencies
      tags: ["核心（上线必须）"]
      summary: 【上线必须】币种目录 + 每币种精度
      description: 与 seamless 产品同一端点定义。全 API 金额字符串的小数位数按此表解释——划拨金额与余额精度都依赖它（加密货币不在 ISO 4217 之列）。无 K 货币单位：大面值币种（IDR/VND/COP 型）直接按真实面值传，换算在 PlayRouter 与厂商之间消化。请求为空对象 `{}`。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: 空对象。
      responses:
        "200":
          description: 币种对象数组。
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Currency"
        "400": { $ref: "#/components/responses/MalformedRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /v1/transfer/transfers/create:
    post:
      operationId: transfersCreate
      tags: ["钱包划拨（上线必须）"]
      summary: 【上线必须】划拨（上分/下分合一，恒正金额）
      description: |
        把余额划入（deposit）或取回（withdraw）玩家在指定厂商侧的游戏钱包。
        方向进 `type` 字段，`amount` 恒为正数——没有带符号金额。

        - **不需要预注册玩家**：deposit 自动建户（上游需要显式建户的厂商由 PlayRouter 内部代办）；
          首笔划拨确定该 player_id 的钱包币种（单币种钱包，此后 currency 不符 → 422 currency_mismatch）。
        - **幂等**：body 必填 `idempotency_key`。拿不到响应（超时/断连）→ **同 key 重发，恒安全（在同一区内）**。
        - **业务结局不是错误**：余额不足、局内锁等以 200 + `status:"failed"` + `reason` 返回（枚举见响应 schema）。
        - **status:"pending"**：这笔划拨仍在确认中、我方会给出终态，极少出现；出现时用 transfers/get 轮询终态（建议 ≥30 秒间隔）。
        - **推荐操作顺序**：上分 = 先扣你侧钱包、成功后再调本端点（失败则退回）；
          下分 = 先调本端点、确认 completed 后再加你侧钱包——"先扣后划、先划后加"，防止双花与丢分。
        - 没有 withdraw_all：下分金额自己定，参考 wallet/balance 的 `transferable`（见 info「没有 withdraw_all」）。
        - **多产品线厂商**：`provider_product` **必填**，指定钱进哪个产品钱包（值取 games/list 回显或 providers/list 的 `products[].code`）；
          单产品线厂商可省略；缺省或非法 → 422 `validation_failed`（fields.provider_product = required / invalid——invalid = 厂商已开通但值不是它的产品线）；
          厂商族本身不在本面提供（未接入或已被平台停用）→ 403 `provider_not_enabled`。响应回显生效值。
        - **两个币种码分工**：厂商 / 实例不收该币种（如 saba 只登记 USD 时的 CNY）→ 422 `currency_not_supported`，换成该厂商支持的币种；
          请求币种 ≠ 该玩家已锁定的钱包币种 → 422 `currency_mismatch`，message 点名钱包币种，按它重发。
        - **从未上分的玩家 withdraw**：200 + `status:"failed"` + `reason:"insufficient_funds"`（该厂商尚无钱包、余额 0），不是 404——
          `player_not_found` 只在本平台从未见过该 player_id 时、且只在 wallet/balance · wallet/balances 出现。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TransfersCreateRequest"
      responses:
        "200":
          description: 划拨已受理并有了业务结局（completed / failed / pending）——三态都在 200 里，HTTP 错误码只留给传输与配置问题。
          headers:
            Idempotency-Replayed:
              description: 仅幂等重放时出现，值恒为 "true"。
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TransfersCreateResponse"
        "400": { $ref: "#/components/responses/MalformedRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /v1/transfer/transfers/get:
    post:
      operationId: transfersGet
      tags: ["运营（强烈建议）"]
      summary: 【可选·推荐】查单笔划拨（pending 收尾 + 排障）
      description: |
        按 `transfer_id` 或 `idempotency_key` 查单笔划拨的当前状态。
        **这不是重试的前置仪式**——拿不到响应直接同 key 重发 transfers/create 即可；
        本端点服务两个场景：① create 返回 `pending` 后轮询终态；② 运维排障/对账反查。
        最近 1 个月在线可查（更早归档调取，与列表查询窗口同口径）。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: transfer_id / idempotency_key 二选一必填。
              oneOf:
                - required: [transfer_id]
                - required: [idempotency_key]
              properties:
                transfer_id:
                  type: string
                  description: 划拨 ID（trf_ 前缀，transfers/create 响应返回）。
                  examples: ["trf_01H8XGJWBW"]
                idempotency_key:
                  $ref: "#/components/schemas/IdempotencyKey"
      responses:
        "200":
          description: 划拨对象。
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Transfer"
        "400": { $ref: "#/components/responses/MalformedRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /v1/transfer/wallet/balance:
    post:
      operationId: walletBalance
      tags: ["钱包划拨（上线必须）"]
      summary: 【上线必须】查玩家游戏钱包余额（含可下分预览）
      description: |
        查询玩家在指定厂商侧游戏钱包的当前余额。`provider` 必填——逐厂商钱包，查哪家指定哪家；
        多产品线厂商还须带 `provider_product`（查哪个产品钱包），缺省 → 422 `validation_failed`（fields.provider_product = required）；
        厂商已开通但值不是它的产品线 → 422 `validation_failed`（fields.provider_product = invalid）；厂商族本身不在本面提供（未接入或已被平台停用）→ 403 `provider_not_enabled`。

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

        **transferable（可下分余额）按厂商能力出现**：厂商有此口径时给出；给不出时**响应缺省该字段**——不编数。
        字段缺省时下分策略退回"按 balance 试、失败按 reason 重试"。
        注意：玩家关闭游戏窗口 ≠ 本局结束，局内锁定的余额要等结算后才可下分。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [player_id, provider]
              properties:
                player_id:
                  type: string
                  maxLength: 64
                  examples: ["wang-123"]
                provider:
                  type: string
                  description: 厂商代号（来自本面 providers/list 的 code）——钱包定位键。
                  examples: ["pg"]
                provider_product:
                  type: string
                  description: 厂商产品维度键：查哪个产品钱包的余额。**多产品线厂商必填**，值取 games/list 回显的 `provider_product` 或 providers/list 的 `products[].code`；单产品线厂商可省略；缺省或非法 → 422 validation_failed（fields.provider_product = required / invalid）。
                  examples: ["card"]
      responses:
        "200":
          description: 余额快照。
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [player_id, provider, currency, balance, as_of]
                    properties:
                      player_id:
                        type: string
                        maxLength: 64
                        examples: ["wang-123"]
                      provider:
                        type: string
                        description: 厂商代号（来自本面 providers/list 的 code）。
                        examples: ["pg"]
                      provider_product:
                        type: string
                        description: 生效的厂商产品维度键（多产品线厂商时出现），明确本快照属于哪个产品钱包。
                        examples: ["card"]
                      currency:
                        $ref: "#/components/schemas/CurrencyCode"
                      balance:
                        $ref: "#/components/schemas/Money"
                        description: 当前余额。玩家在该厂商尚无钱包 / 从未上分 → "0.00"（不是 404，0 是事实）。
                      transferable:
                        $ref: "#/components/schemas/Money"
                        description: 可下分余额预览。仅厂商提供此口径时出现；缺省 = 厂商无此数据，按 balance 试错。
                      as_of:
                        $ref: "#/components/schemas/Timestamp"
        "400": { $ref: "#/components/responses/MalformedRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /v1/transfer/wallet/balances:
    post:
      operationId: walletBalances
      tags: ["运营（强烈建议）"]
      summary: 【可选】厂商内全产品/场馆余额归集（逐行快照，非总资产承诺）
      description: |
        一次查询玩家在**同一厂商**全部产品线游戏钱包的余额（多产品线厂商，如 db）。
        缺省扇出范围 = 该玩家在该厂商**有过划拨记录的产品线集合**；
        也可用 `products` 显式指定（值域 = providers/list 的 `products[].code`）。单产品线厂商退化为单行（等价 wallet/balance）。

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

        **逐行独立 `as_of`**：余额是逐产品顺序查询的，各行快照时刻不同——我们如实给出每行的取数时刻，
        不假装这是同一时点的原子快照。

        **partial 反模式警示（务必阅读）**：任何一行查询失败时该行 `available=false`、顶层
        `partial=true` 且 **`total_balance` 缺省**。**切勿把可得行的 balance 求和当作玩家在该厂商的
        总资产**——缺的那行可能正是钱所在的场馆。正确姿势：`partial=true` 时按行重试（对失败行
        用 `products` 指定重查），全部行可得后再使用 `total_balance`。

        **调用频率**：本端点较重（要逐个产品查询），
        请勿高频轮询——建议同一玩家的归集查询间隔 ≥30 秒；高频场景用 wallet/balance 点查目标产品。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [player_id, provider]
              properties:
                player_id:
                  type: string
                  maxLength: 64
                  examples: ["wang-123"]
                provider:
                  type: string
                  description: 厂商代号（来自本面 providers/list 的 code）——归集范围恒为单厂商。
                  examples: ["db"]
                products:
                  type: array
                  maxItems: 32
                  items:
                    type: string
                  description: 可选。显式扇出集：只查这些产品线（值取 games/list 回显的 `provider_product` 或 providers/list 的 `products[].code`）。逐值校验，任何非法值 → 422 validation_failed（不部分执行）。缺省 = 该玩家在该厂商有过划拨记录的产品线集合。
                  examples: [["card", "live"]]
      responses:
        "200":
          description: 逐产品/场馆余额快照集。任何单行失败不影响 200——看 `partial` 与各行 `available`。
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [player_id, provider, currency, wallets, partial]
                    properties:
                      player_id:
                        type: string
                        maxLength: 64
                        examples: ["wang-123"]
                      provider:
                        type: string
                        description: 厂商代号（来自本面 providers/list 的 code）。
                        examples: ["db"]
                      currency:
                        $ref: "#/components/schemas/CurrencyCode"
                      wallets:
                        type: array
                        description: 逐产品/场馆一行；失败行保留在列表中（available=false），不剔除。
                        items:
                          type: object
                          required: [as_of, available]
                          properties:
                            provider_product:
                              type: string
                              description: 该行所属厂商产品维度键（单产品线厂商的单行不出该字段），值域同 providers/list 的 `products[].code`。
                              examples: ["card"]
                            balance:
                              $ref: "#/components/schemas/Money"
                              description: 该产品钱包余额；仅 available=true 时出现。
                            transferable:
                              $ref: "#/components/schemas/Money"
                              description: 可下分预览，仅厂商有此口径且行可得时出现（与 wallet/balance 同一降级规则）。
                            as_of:
                              $ref: "#/components/schemas/Timestamp"
                              description: 本行取数时刻——逐行独立（顺序扇出有时差），不是全响应统一时点。
                            available:
                              type: boolean
                              description: false = 本行余额暂不可得（厂商暂时不可达/超时/预算耗尽），balance 缺省；稍后重试或用 products 指定重查。
                      total_balance:
                        $ref: "#/components/schemas/Money"
                        description: 全部行可得时的余额合计。partial=true 时**恒缺省**——勿以可得行之和充当总资产。
                      partial:
                        type: boolean
                        description: true = 至少一行不可得。此时 total_balance 缺省，按行重试收敛。
        "400": { $ref: "#/components/responses/MalformedRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /v1/transfer/records/list:
    post:
      operationId: recordsList
      tags: ["对账（上线必须）"]
      summary: 【上线必须】游戏记录流（你知道输赢的唯一渠道）
      description: |
        拉取时间段内的游戏记录。**转账模式下游戏期间你是全盲的**——本端点是你知道玩家输赢的唯一渠道，
        是主账本不是复核视图，故属上线必须。与 seamless 产品的 transactions/list 是不同资源：
        这里的粒度是厂商侧游戏记录（含 `valid_bet` 有效投注），不是钱包回调交易流。

        **数据源与约束**：数据从各厂商拉取归一——新鲜度分钟级；
        可查窗口统一最近 1 个月，`from` 越界的请求 → 422 validation_failed + fields 指明。
        个别记录可能因平台侧处理延后出现，处理完成后自动补入、无需商户操作；补入的记录 `recorded_at` 为补入时刻，
        出现在该时刻所在的时间窗——按时间窗增量拉取不会漏，按 `record_id` 去重即可。
        记录顺序稳定但不保证按时间排序；用 `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）净额恒正确（2026-07-20 语义收窄，见下方求和口径）。示例（原局赢 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}`

        **求和口径与体育两条一单（2026-07-20 语义收窄）**：一切金额求和**只对 status=settled 的记录做**。
        `unsettled` 记录是体育型投注单的首见快照（bet=原注额、win=0、无 settled_at），**首见固化不保新**——
        结算时不修改它，而是另追加一条 settled 全量结算记录（同 provider_record_id / round_id，
        「投注单与结算单各一条」即这两条，结算单不带 ref_record_id）；`canceled` / `refunded` 记录
        **金额保留原注额**、仅作事实快照，不参与求和。冲销记录的 status 与被冲销记录一致，同进同出。。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [from, to]
              properties:
                from:
                  $ref: "#/components/schemas/Timestamp"
                  description: 开始时间（含），比较记录的 recorded_at（进入本平台的时刻）。
                to:
                  $ref: "#/components/schemas/Timestamp"
                  description: 结束时间（不含）。from <= recorded_at < to。
                cursor:
                  type: ["string", "null"]
                  description: 上页响应的 next_cursor；首页传 null 或省略。黑盒游标：内容不作承诺，原样传回即可。
                limit:
                  type: integer
                  minimum: 1
                  maximum: 2000
                  examples: [2000]
                player_id:
                  type: string
                  maxLength: 64
                  description: 可选过滤（客诉排查免全量下载）。
                provider:
                  type: string
                  description: 可选过滤。厂商代号（来自本面 providers/list 的 code）。
                  examples: ["pg"]
      responses:
        "200":
          description: 游戏记录分页（对象数组，自描述）。
          content:
            application/json:
              schema:
                type: object
                required: [data, has_more]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Record"
                  next_cursor:
                    type: ["string", "null"]
                  has_more:
                    type: boolean
        "400": { $ref: "#/components/responses/MalformedRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /v1/transfer/records/get:
    post:
      operationId: recordsGet
      tags: ["运营（强烈建议）"]
      summary: 【可选·推荐】单笔游戏记录 + 牌局详情链接（客诉单查）
      description: |
        按 `record_id` 查单笔游戏记录，**最近 1 个月在线可查（更早归档调取，与列表查询窗口同口径）**——客诉是单笔场景，不用重拉整段时间的列表定位那一条。
        响应在 Record 对象之外可选给 `detail_url`（厂商牌局详情页链接，给客服/玩家看的"证据页"）；
        厂商给不出详情页则缺省该字段（声明降级，不编造）。`locale` 影响详情页语言（厂商支持时）。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [record_id]
              properties:
                record_id:
                  type: string
                  description: 记录 ID（rec_ 前缀，records/list 返回）。
                  examples: ["rec_01H8XGJWBW"]
                locale:
                  type: string
                  pattern: "^[a-z]{2}$"
                  description: 可选，详情页语言；不支持的值静默降级 en。
      responses:
        "200":
          description: 单笔记录；detail_url 仅厂商提供详情页时出现。
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    allOf:
                      - $ref: "#/components/schemas/Record"
                      - type: object
                        properties:
                          detail_url:
                            type: string
                            description: 厂商牌局详情页链接（客诉证据页）。仅厂商提供时出现。
                            examples: ["https://records.provider.example/round/abc?lang=pt"]
        "400": { $ref: "#/components/responses/MalformedRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

components:
  parameters:
    KeyId:
      name: X-Playrouter-Key-Id
      in: header
      required: true
      description: 使用的密钥 ID（pk_live_… / pk_test_…，账号级、两条产品线通用）。PlayRouter 按它选择对应 secret 验签，支持双 key 并行轮换。
      schema:
        type: string
        examples: ["pk_live_01H8XGJWBWBAQ4"]
    Signature:
      name: X-Playrouter-Signature
      in: header
      required: true
      description: |
        `t=<unix秒>,v1=<hex>`。你签名：signed_payload = "{t}" + "." + "{raw_body}"，
        v1 = HMAC-SHA256(secret, signed_payload) 的十六进制小写；序列化后不得再格式化 body。
        |now - t| > 300 秒直接拒绝（防重放）。验签失败 → 401（invalid_signature / invalid_timestamp / unknown_key）。
      schema:
        type: string
        examples: ["t=1719792000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"]

  responses:
    MalformedRequest:
      description: "400：body 非合法 JSON。修 bug。"
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    Unauthorized:
      description: "401：签名/时间戳/密钥问题（invalid_signature / invalid_timestamp / unknown_key）。修配置，告警。"
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    Forbidden:
      description: "403：无权限（account_suspended / feature_not_enabled / product_not_enabled / ip_not_allowed）——联系配置；ip_not_allowed 为可选的来源 IP 白名单拒绝，把出口 IP 加入 console 名单即可。provider_not_enabled 不是账号配置问题：本面当前不提供该厂商（或产品线）——未接入本面，或已被平台停用；以本面 providers/list 为准。"
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    NotFound:
      description: "404：路径不存在，或引用的资源不存在（not_found / game_not_found / player_not_found / transfer_not_found / record_not_found / session_not_found）——含存在但不属于你的资源（越权响应不可分辨，防枚举）。修引用。player_not_found 仅指本平台从未见过该 player_id（从未有一次被受理的 transfers/create——返回 200、不论 completed / failed——也从未成功建过真钱会话；被 4xx 拒的请求不登记玩家）；玩家已登记、只是在该厂商尚无钱包 → wallet/balance 回 200 balance 0.00、withdraw 回 200 failed insufficient_funds，不是 404。"
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    IdempotencyConflict:
      description: "409：idempotency_key_reused——同 key 不同 body。修 bug，绝不执行。"
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    ValidationFailed:
      description: "422：字段校验失败（validation_failed / currency_not_supported / currency_mismatch / game_not_available / demo_not_available）。修请求；fields 给逐字段明细。transfers/create 的 amount 小数位超过该厂商接口能接受的位数（个别厂商少于币种目录，例如最多 3 位）返 validation_failed，fields.amount = exceeds N decimals accepted by provider——不建单、资金未动，改成该位数以内重新发起即可。两个币种码分工：currency_not_supported = 厂商 / 游戏 / 平台不支持该币种（如 saba 只登记 USD 时的 CNY 上分或 real 启动，换成厂商支持的币种）；currency_mismatch = 请求币种 ≠ 该玩家已锁定的钱包币种（message 点名钱包币种，按它重发）。real 启动遇该币种平台侧暂时不可用时也返 currency_not_supported，fields.currency = temporarily unavailable（固定值，可按它程序判断）——不要换币种（钱包币种在首次划拨 / 首次真钱启动时锁定，换币种要么撞 currency_mismatch、要么把新玩家锁成错误币种），稍后重试同一请求；上下分不受影响；玩家钱包已锁定其它币种时先返 currency_mismatch。sessions/create 遇平台侧暂时不可用时返 game_not_available，fields.game_code = temporarily unavailable（固定值）——游戏没有下架，不要从大厅撤下，稍后重试同一请求；不带该标记的 game_not_available（含厂商拒绝该玩家进入）重试不会成功。"
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    RateLimited:
      description: "429：rate_limited。按 Retry-After 重试。"
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
        Retry-After:
          description: 建议等待秒数。
          schema:
            type: integer
        RateLimit-Limit:
          schema: { type: integer }
        RateLimit-Remaining:
          schema: { type: integer }
        RateLimit-Reset:
          schema: { type: integer }
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    ServerError:
      description: "5xx / 超时：服务故障（internal_error / provider_error / service_unavailable；provider_timeout 为预留码，目前不会出现）。幂等保障下可安全重试（transfers/create 同 idempotency_key）。502 仅承载上游瞬态故障（确定性拒绝映射为对应 4xx 语义码）。基础设施层 5xx 的 body 可能非 JSON——解析失败直接按 HTTP 码行动。"
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"

  headers:
    XRequestId:
      description: 请求追踪 ID（req_ 前缀），与错误体内 request_id 同值。
      schema:
        type: string
        examples: ["req_01H8XGJWBW"]

  schemas:
    Money:
      type: string
      pattern: '^\d{1,18}(\.\d{1,12})?$'
      description: 金额一律字符串十进制，永远非负（本产品没有带符号金额——划拨方向在 type 字段）。唯一例外 = records 的冲销记录（source=adjustment 行，见 SignedMoney 与 Record.ref_record_id，2026-07-19 破例条款）。小数位按 /v1/transfer/currencies 公布的每币种 decimals 写满，零也一样（2 位币种写 "0.00"）。请按十进制数值解析与比较，不要按字符串比较。无 K 货币单位换算。
      examples: ["100.00", "0.50", "0.00"]

    SignedMoney:
      type: string
      pattern: '^-?\d{1,18}(\.\d{1,12})?$'
      description: 带符号金额字符串——**仅 records 的冲销记录（source=adjustment 且带 ref_record_id 的行）取负值**，表示冲销被引用记录的对应数额；其余一切记录与端点的金额恒非负（同 Money）。对 status=settled 的记录求和（Σ）净额天然正确（unsettled/canceled/refunded 行不参与求和——见 Record.status 说明）。
      examples: ["-10.00", "10.00"]

    CurrencyCode:
      type: string
      pattern: "^[A-Z0-9]{3,4}$"
      description: 法币 = ISO 4217 三字母（BRL）；加密货币 = 3–4 位字母数字（USDT）。
      examples: ["BRL", "USDT"]

    IdempotencyKey:
      type: string
      pattern: "^[A-Za-z0-9_.-]{1,128}$"
      description: |
        运营商生成的幂等键。推荐确定性派生（如 "tf-{内部订单ID}"）+ write-ahead 落库；
        **严禁每次重试新生成**。作用域运营商账号级；保留 7 天。
        命中重放返回原响应 + Idempotency-Replayed: true；同 key 不同 body → 409。
      examples: ["tf-ord-889231"]

    Timestamp:
      type: string
      format: date-time
      description: RFC 3339 UTC 恒 3 位毫秒。
      examples: ["2026-07-06T12:00:00.000Z"]

    ErrorResponse:
      type: object
      required: [error]
      description: 每个 4xx/5xx 的固定错误体（唯一例外：基础设施层 5xx 可能非 JSON）。错误码目录与 seamless 产品共用 §5.2，转账产品增补 player_not_found / transfer_not_found / currency_mismatch 三码（record_not_found 两面都有）。
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code:
              type: string
              description: 语义字符串错误码（不用数字）。未识别的 code 按其 HTTP 码的动作处理。
              examples: ["currency_mismatch"]
            message:
              type: string
              description: 人读描述（只进日志，不要求逐个 catch）。
              examples: ["Wallet currency is BRL; request says USD."]
            doc_url:
              type: string
              description: 该错误码的文档页。
              examples: ["https://docs.playrouter.io/errors/currency_mismatch"]
            request_id:
              type: string
              description: 请求追踪 ID（同响应头 X-Request-Id）。
              examples: ["req_01H8XGJWBW"]
            fields:
              type: object
              description: 逐字段校验明细（422 时给）。
              additionalProperties:
                type: string
              examples:
                - { currency: "wallet currency is BRL" }

    SessionsCreateRequest:
      type: object
      required: [game_code, currency, mode]
      properties:
        player_id:
          type: string
          maxLength: 64
          description: 运营商侧稳定玩家标识。real 必填；demo 可选。全 API 唯一的玩家身份面——不收 PII。单币种钱包：一个 player_id 一个币种。
          examples: ["wang-123"]
        game_code:
          type: string
          description: |
            游戏标识 = `<provider>:<游戏段>`，**不透明键**，与 games/list 的 game_code 同一把键，原样传回即可；厂商段同时决定资金所在的游戏钱包。
            多产品线厂商（目前只有 `db`）的游戏段形如 `card-101`（产品线-厂商游戏码）；单产品线厂商的游戏段就是厂商游戏码原文。
            商户不要自行拆游戏段——产品维度用 games/list 回显的 `provider_product` 字段。
            示例：`pg:fortune-tiger`（单产品线）、`db:card-101`（多产品线 db 的 card 产品线）。
          examples: ["pg:fortune-tiger", "db:card-101"]
        currency:
          $ref: "#/components/schemas/CurrencyCode"
        mode:
          type: string
          enum: [real, demo]
          description: 真钱/试玩合一端点，模式进字段。demo 的 currency = 期望展示币种（不支持则静默降级厂商默认）；real 的 currency = 钱包币种（精确匹配永不降级）。
        locale:
          type: string
          pattern: "^[a-z]{2}$"
          description: 可选，ISO 639-1 小写；不支持的值静默降级 en。
          examples: ["pt"]
        device:
          type: string
          enum: [desktop, mobile]
          description: 可选；厂商需要时转换层给默认值。
        lobby_url:
          type: string
          description: 可选，退出游戏返回地址。
          examples: ["https://op.com/lobby"]
        deposit_url:
          type: string
          description: 可选，玩家游戏内余额不足时厂商"去充值"按钮的跳转地址。厂商支持则透传；未提供时转换层降级用 lobby_url 补位。
          examples: ["https://op.com/cashier"]
        launch_mode:
          type: string
          enum: [url, html]
          default: url
          description: |
            可选。"html" = 请求 HTML 直出（游戏从你的自有域名伺服，抗封锁）；
            厂商只有 URL 形态时声明降级：返 url、html 缺省、不报错。游戏能力看 games/list 的 launch_modes。
        ip:
          type: string
          description: 玩家 IP（IPv4/IPv6）。real 必填；demo 可省略。
          examples: ["203.0.113.7"]
        session_payload:
          type: string
          maxLength: 1024
          description: 可选透传串（与 seamless 产品同形保留；转账模式无回调，该串会出现在 records 的会话关联与排障日志中）。
          examples: ["wsess-8f2|ios"]

    SessionsCreateResponse:
      type: object
      required: [session_id, url, expires_at]
      properties:
        session_id:
          type: string
          description: PlayRouter 侧会话 ID（ses_）——排障/终止关联的锚点。
          examples: ["ses_01H8XGJWBW"]
        url:
          type: string
          description: |
            启动 URL，**恒在**（HTML 直出型厂商由 PlayRouter 托管 launcher 包装）。
            拿到后立即打开、只用一次：链接有效期由游戏厂商决定，短的只有约 1 分钟，部分厂商的链接只能打开一次；
            玩家每次进游戏（含刷新页面、重新打开）都重新调用 sessions/create 取新链接，不要缓存复用。
          examples: ["https://launch.playrouter.io/s/ses_01H8XGJWBW"]
        html:
          type: string
          description: 仅 launch_mode:"html" 且厂商支持 HTML 直出时出现——完整 HTML 文档，从你的自有域名伺服。
          examples: ["<!DOCTYPE html>..."]
        expires_at:
          $ref: "#/components/schemas/Timestamp"
          description: 启动 URL 最晚失效时刻；实际有效期由游戏厂商决定、可能早得多（见 url），不要据此缓存复用 url。

    GamesListRequest:
      type: object
      properties:
        cursor:
          type: ["string", "null"]
          description: 上页响应的 next_cursor；首页传 null 或省略。黑盒游标：内容不作承诺，原样传回即可。
        limit:
          type: integer
          minimum: 1
          maximum: 2000
          description: 每页条数。
          examples: [500]
        updated_after:
          $ref: "#/components/schemas/Timestamp"
        provider:
          type: string
          description: 可选过滤（单值；多厂商 = 多次调用或不过滤）。厂商代号（来自本面 providers/list 的 code）。
          examples: ["pg"]
        category:
          type: string
          description: 可选过滤（live 为一等品类）。取值 = 游戏类型字典（2026-09-07 共 17 码）：sports, live, slots, lottery, card, poker, fishing, esports, virtual, originals, racing, cockfighting, hash, arcade, table, prediction, scratch；字典只增不改，客户端对未知值按「其它」兜底。
          examples: ["slots"]
        currency:
          $ref: "#/components/schemas/CurrencyCode"

    GamesListResponse:
      type: object
      required: [data, has_more]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Game"
        next_cursor:
          type: ["string", "null"]
        has_more:
          type: boolean

    Game:
      type: object
      required: [game_code, name, provider, category, locales, currencies, devices, launch_modes, status, updated_at]
      properties:
        game_code:
          type: string
          description: |
            全 API 同一把游戏键 = `<provider>:<游戏段>`，**不透明键**。
            多产品线厂商（目前只有 `db`）的游戏段形如 `card-101`（产品线-厂商游戏码）；单产品线厂商的游戏段就是厂商游戏码原文。
            商户不要自行拆游戏段，产品维度看本对象的 `provider_product`。示例：`pg:fortune-tiger`、`db:card-101`。
          examples: ["pg:fortune-tiger", "db:card-101"]
        name:
          type: string
          examples: ["Fortune Tiger"]
        provider:
          type: string
          description: 厂商代号（来自本面 providers/list 的 code；厂商族码，如 pg / db / pp / saba）。v1 全直连，provider 即工作室品牌；无 producer 双层维度。转账模式下同时是钱包定位键。
          examples: ["pg"]
        provider_product:
          type: string
          description: 可选。厂商产品维度键——多产品线厂商才出现（如 db 的 live / hash / card / boyacard / fishing / boyafishing / slots / scratch / lottery / esports / sports），值与 providers/list 的 `products[].code` 同一命名空间；transfers/create 与 wallet/balance 同名字段的取值来源之一。产品维度以本字段为准，不要自行拆 game_code 的游戏段。
          examples: ["card"]
        category:
          type: string
          description: 品类。取值 = 游戏类型字典（2026-09-07 共 17 码）：sports, live, slots, lottery, card, poker, fishing, esports, virtual, originals, racing, cockfighting, hash, arcade, table, prediction, scratch；字典只增不改，客户端对未知值按「其它」兜底。
          examples: ["slots"]
        images:
          type: object
          description: 可选（不一定有）。图片 URL——只在厂商目录提供图源时出现；缺省 = 厂商无图源，不编图（2026-09-19 起由必有改为可选）。
          properties:
            square: { type: string }
            landscape: { type: string }
        locales:
          type: array
          description: |
            总是有。支持语言（ISO 639-1 两字母）。**厂商实例级**：由平台运营按厂商实例登记，同一厂商实例下所有游戏同值，
            不保证每款游戏各异；未登记缺省 ["en"]。含义 = 该厂商启动接口可传的语言；不在表内的 locale 启动时静默降级 en。
          items: { type: string }
          examples: [["en", "zh", "th"]]
        currencies:
          type: array
          description: |
            总是有。支持币种。**厂商实例级**：由平台运营按厂商实例登记，同一厂商实例下所有游戏同值，不保证每款游戏各异；
            未登记缺省 ["CNY"]。以此判断 sessions/create · transfers/create 该传什么币种；实例真正接受的币种以
            422 currency_not_supported 为准（fields.currency = temporarily unavailable 的除外：那是平台侧暂时不可用，不代表实例不收；目录里出现该实例不接受的币种属运营登记错误，平台侧告警修正；目录只列部分接受币种是正常经营选择）。
          items:
            $ref: "#/components/schemas/CurrencyCode"
        devices:
          type: array
          items:
            type: string
            enum: [desktop, mobile]
        launch_modes:
          type: array
          description: 启动形态能力：含 "html" 即支持 HTML 直出（抗封锁），配合 sessions/create 的 launch_mode。
          items:
            type: string
            enum: [url, html]
          examples: [["url", "html"]]
        features:
          type: object
          description: 可选（不一定有）。能力布尔——只在厂商目录给出能力信息时出现；缺省 = 未知，不编假 false（2026-09-19 起由必有改为可选）。
          properties:
            demo: { type: boolean }
            freespins: { type: boolean }
            bonus_buy: { type: boolean }
            jackpot: { type: boolean }
        bet_levels:
          type: object
          description: 可选（不一定有）。币种 → 合法投注面值（金额字符串数组），只在厂商提供面值表时出现；缺省 = 厂商不给。
          additionalProperties:
            type: array
            items:
              $ref: "#/components/schemas/Money"
          examples:
            - { BRL: ["0.50", "1.00", "5.00"] }
        rtp:
          type: string
          description: 可选（不一定有）。RTP 百分数字符串，只在厂商提供时出现；已接厂商的目录接口均不含 RTP，缺省不编数。
          examples: ["96.81"]
        volatility:
          type: string
          description: 可选。厂商波动率评级**原值透传**（各家口径不一，不强行归一枚举）。
          examples: ["high"]
        released_at:
          $ref: "#/components/schemas/Timestamp"
          description: 可选。发布时刻——此前启动返回 game_not_available。
        forbid_bonus_play:
          type: boolean
          description: 可选。true = 该游戏禁止用奖金资金游玩（运营商自建奖金系统的合规输入）。
        status:
          type: string
          enum: [active, recalled]
          description: 目录只含运营已上架、且所属厂商（多产品线厂商按产品线）当前由平台开放的游戏；active = 厂商在售，recalled = 厂商已下架（下架即一次 update：status:"recalled" + updated_at 变更，增量同步天然捕获）。运营关闭上架的游戏、平台停用的厂商（产品线）的游戏会直接从目录消失，这类消失不经 updated_after 增量下发，由 PlayRouter 商务另行通知；也可自行核对：厂商（产品线）停用时它会从 providers/list 中消失，单款游戏关闭上架可不带 updated_after 全量拉取比对。平台恢复该厂商后，其游戏重新出现、updated_at 随之刷新。
        updated_at:
          $ref: "#/components/schemas/Timestamp"

    Provider:
      type: object
      description: 厂商族一行（每族一行，不按实例展开）。
      required: [code, name, categories, currencies, status]
      properties:
        code:
          type: string
          description: 厂商代号（厂商族码，如 pg / db / pp / saba）——全 API `provider` 字段的取值来源。
          examples: ["pg"]
        name:
          type: string
          examples: ["PG Soft"]
        categories:
          type: array
          description: 总是有。本面该族目录中（运营已上架）游戏 category 的去重集合；一个类目下最后一款下架即从此消失（可为空数组）。
          items: { type: string }
          examples: [["slots"]]
        currencies:
          type: array
          description: 总是有。本面该族各实例登记的 currencies 的并集（运营按实例登记，未登记缺省 CNY，与 games/list 同源）。
          items:
            $ref: "#/components/schemas/CurrencyCode"
        products:
          type: array
          description: 可选。厂商产品维度键清单——多产品线厂商（目前只有 db）才出现，单产品线厂商缺省。供 lobby 分区展示与 `provider_product` 取值发现（transfers/create、wallet/balance、wallet/balances.products）；`code` 与 games/list 回显的 `provider_product` 同一命名空间。
          items:
            type: object
            required: [code, name]
            properties:
              code:
                type: string
                description: 产品维度键（如 db 的 live / hash / card / boyacard / fishing / boyafishing / slots / scratch / lottery / esports / sports）。
                examples: ["card"]
              name:
                type: string
                description: 产品展示名。
                examples: ["Card Games"]
          examples:
            - [{ code: "card", name: "Card Games" }, { code: "live", name: "Live Casino" }]
        status:
          type: string
          enum: [active, disabled]
          description: 目前恒为 active：列表只含本面当前开放的厂商族；厂商族整体停用时直接从列表消失、某条产品线停用时从 products 中消失，lobby 整区下架以此为准（disabled 暂不下发）。

    Currency:
      type: object
      required: [code, decimals, type]
      properties:
        code:
          $ref: "#/components/schemas/CurrencyCode"
        decimals:
          type: integer
          minimum: 0
          maximum: 12
          description: 金额字符串的小数位数。
          examples: [2]
        type:
          type: string
          enum: [fiat, crypto]

    TransfersCreateRequest:
      type: object
      required: [player_id, provider, type, amount, currency, idempotency_key]
      properties:
        player_id:
          type: string
          maxLength: 64
          description: 运营商侧稳定玩家标识。deposit 自动建户；首笔划拨确定钱包币种（单币种钱包）。
          examples: ["wang-123"]
        provider:
          type: string
          description: 厂商代号（来自本面 providers/list 的 code）——钱去哪家/从哪家取回。逐厂商钱包，必填。
          examples: ["pg"]
        provider_product:
          type: string
          description: |
            厂商产品维度键——多产品线厂商（同一厂商代号下有多个独立产品钱包，目前只有 db）时指定钱进哪个产品钱包。
            **多产品线厂商必填**，值取 games/list 回显的 `provider_product` 或 providers/list 的 `products[].code`（值不猜、不从 game_code 反推）；
            单产品线厂商可省略。缺省或非法 → 422 validation_failed（fields.provider_product = required / invalid），不会挂单；响应回显生效值。
          examples: ["card"]
        type:
          type: string
          enum: [deposit, withdraw]
          description: deposit = 你侧 → 厂商侧游戏钱包（上分）；withdraw = 取回（下分）。金额恒正，方向只在这里。
        amount:
          $ref: "#/components/schemas/Money"
          description: 恒正金额，精度按 /v1/transfer/currencies 的 decimals。个别游戏厂商的接口能接受的小数位少于币种目录（例如最多 3 位），超出时当场 422 validation_failed，fields.amount 写明上限（exceeds N decimals accepted by provider），不建单、资金未动；改成该位数以内重新发起即可。
        currency:
          $ref: "#/components/schemas/CurrencyCode"
          description: 一致性防呆：与该玩家已锁定的钱包币种不符 → 422 currency_mismatch（首笔划拨时即确定钱包币种）；厂商 / 实例本身不收该币种 → 422 currency_not_supported（如 saba 只登记 USD 时的 CNY，换成厂商支持的币种而不是玩家钱包币种）。
        idempotency_key:
          $ref: "#/components/schemas/IdempotencyKey"

    TransfersCreateResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/Transfer"

    Transfer:
      type: object
      required: [transfer_id, player_id, provider, type, amount, currency, status, created_at]
      description: 划拨对象（transfers/create 与 transfers/get 同一形状——列表/单查不搞两套 schema）。
      properties:
        transfer_id:
          type: string
          description: 划拨 ID（trf_ 前缀），transfers/get 的查询主键。响应请持久化。
          examples: ["trf_01H8XGJWBW"]
        player_id:
          type: string
          maxLength: 64
          examples: ["wang-123"]
        provider:
          type: string
          description: 厂商代号（来自本面 providers/list 的 code）。
          examples: ["pg"]
        provider_product:
          type: string
          description: 生效的厂商产品维度键（多产品线厂商时出现）。钱实际进/出的产品钱包，自记对账以此为准。
          examples: ["card"]
        type:
          type: string
          enum: [deposit, withdraw]
        amount:
          $ref: "#/components/schemas/Money"
        currency:
          $ref: "#/components/schemas/CurrencyCode"
        status:
          type: string
          enum: [completed, failed, pending]
          description: |
            completed / failed 是终态，不会再变。pending = 这笔划拨仍在确认中（极少出现），我方会给出终态；
            用 transfers/get 轮询（建议 ≥30 秒间隔），后续必然落到 completed 或 failed。
        reason:
          type: string
          description: |
            仅 status:"failed" 时出现。枚举（统一命名）：
            `insufficient_funds`（下分：游戏钱包余额不足）/ `game_limit`（该游戏局内禁划拨，退出或等局结束后重试）/
            `player_in_game`（玩家正在游戏中、此时不能划拨，稍后重试）/ `player_blocked`（玩家被厂商或风控封禁）/
            `amount_out_of_range`（超出厂商单笔/余额上限——目前基本不出现，只在我方人工核对后落终态时可能给出；改金额后换 idempotency_key 重新发起）/ `provider_unavailable`（厂商暂不可用——资金未动，稍后换 idempotency_key 重新发起；同 key 只会重放这次 failed）/
            `provider_rejected`（厂商确定性拒绝——参数/单号/风控类，重试同样被拒；终态，记录并人工处理）/
            `not_executed`（这笔划拨确认未被执行——资金未动；上分退回扣款、下分不入账，可换 idempotency_key 重新发起；终态，不会再变）。
            枚举可增补（按加法），未识别的 reason 按"记录并人工处理"兜底。pending 超过 1 小时未收敛即转我方人工介入，终态仍经 transfers/get 给出。
          examples: ["insufficient_funds"]
        balance:
          $ref: "#/components/schemas/Money"
          description: 划拨完成后（或紧随其后查到的）玩家游戏钱包余额。completed 时通常带上，个别情况下可能缺省（不影响划拨结论；需要当前余额请调 wallet/balance）；failed 时给得出则附上（余额未变化）。
        transferable:
          $ref: "#/components/schemas/Money"
          description: 可下分余额预览，仅厂商提供此口径时出现（与 wallet/balance 同一降级规则）。
        created_at:
          $ref: "#/components/schemas/Timestamp"
        completed_at:
          $ref: "#/components/schemas/Timestamp"
          description: 终态时刻（completed / failed 时出现）。

    Record:
      type: object
      required: [record_id, player_id, provider, game_code, category, currency, round_id, bet, valid_bet, win, source, status, started_at]
      description: 游戏记录对象（records/list 与 records/get 同一形状）。粒度因游戏类型而异（多数游戏一局一条；捕鱼/老虎机型可能整段合并一条；体育型投注单与结算单各一条——两条按同一 provider_record_id / round_id 配对，结算单不带 ref_record_id，该字段是冲销链专用）。
      properties:
        record_id:
          type: string
          description: 记录唯一 ID（rec_ 前缀）——跨页去重主键、records/get 查询键。
          examples: ["rec_01H8XGJWBW"]
        player_id:
          type: string
          maxLength: 64
          examples: ["wang-123"]
        provider:
          type: string
          description: 厂商代号（来自本面 providers/list 的 code）。
          examples: ["pg"]
        provider_product:
          type: string
          description: 可选回显。厂商产品维度键（多产品线厂商时出现），值域同 games/list 的 `provider_product` / providers/list 的 `products[].code`。
          examples: ["card"]
        game_code:
          type: string
          description: 游戏键 `<provider>:<游戏段>`（不透明键，与 games/list 同一把）。
          examples: ["pg:fortune-tiger", "db:card-101"]
        category:
          type: string
          description: |
            品类，值域同 games/list 的 category（游戏类型字典 17 码）。按记录的 game_code 查目录得到，入账时快照（日后类目改映不追溯改写历史）；
            目录查不到该码时若该厂商实例只有一个品类（体育 / 真人类大厅厂商）则取它，否则为空串 ""（不编）。键恒在。
          examples: ["slots"]
        game_detail:
          type: string
          description: 可选。记录带有比游戏键更细的玩法信息（体育项目、桌台、彩种等）时给出其可读名称，只供显示；不要用作分组或关联键，分组请用 `game_code` / `category`。
          examples: ["Soccer", "BaccaratClassic"]
        currency:
          $ref: "#/components/schemas/CurrencyCode"
        round_id:
          type: string
          description: |
            厂商侧回合标识（归一后）。
            **注意：单一钱包面同名字段是我方回合 ID，两面含义不同。**
          examples: ["rnd_9f2k1"]
        bet:
          $ref: "#/components/schemas/SignedMoney"
          description: 原始投注额（未排除对押/刷水）。仅冲销记录（source=adjustment）可为负（= 冲销被引用记录的投注额）。
        valid_bet:
          $ref: "#/components/schemas/SignedMoney"
          description: 有效投注额（已排除对押/刷水）。**计算反水或杀率务必用这个字段，不要用 bet**——冲销记录的负值参与求和使反水基数天然净额正确。
        win:
          $ref: "#/components/schemas/SignedMoney"
          description: 派彩额。玩家输赢 = win − bet（可推导，故不设冗余差额字段）——该推导式对冲销记录的负值同样成立；对 status=settled 的记录求和即净额（其余状态不参与求和，见 status 说明）。
        source:
          type: string
          description: 资金语义（按经济含义命名，可按加法增补）：`game`（常规局）/ `freespin`（免费旋转局）/ `promo`（厂商活动发放）/ `adjustment`（补偿更正——厂商侧正向补发，**以及本产品的冲销记录：金额为负、恒带 ref_record_id**，见 records/list 的改判说明）。
          examples: ["game"]
        status:
          type: string
          enum: [unsettled, settled, canceled, refunded]
          description: 语义字符串（不用数字枚举）。unsettled 主要见于体育型投注单（首见快照、固化不更新，结算时另追加 settled 记录）；canceled/refunded 行金额保留原注额、不参与求和；refunded 仅限全额退，部分退款以 settled+调整后金额的更正记录下发。
        jackpot:
          type: object
          description: 可选，仅该局含彩池派彩时出现（不设平铺恒 0 列）。
          properties:
            win:
              $ref: "#/components/schemas/SignedMoney"
        provider_record_id:
          type: string
          description: 厂商侧原始单号——客诉排障时报给我们。同一原始单号在改判时会对应多条记录（原/冲销/更正各有独立 record_id）。
          examples: ["8865512900"]
        ref_record_id:
          type: string
          description: 可选（2026-07-19 加法新增）。冲销/更正记录指向被冲销原记录的 record_id（rec_）——有值即表示本条是修正链成员；**原记录永不被修改或删除**。
          examples: ["rec_01H8XGJWBW"]
        session_id:
          type: string
          description: 可选，关联的 PlayRouter 会话（ses_，能归属时给）。
          examples: ["ses_01H8XGJWBW"]
        started_at:
          $ref: "#/components/schemas/Timestamp"
          description: 本局开始时间（合并型记录 = 进房时间，照厂商口径转述）。
        settled_at:
          $ref: "#/components/schemas/Timestamp"
          description: 结算时间；unsettled 时缺省。
        recorded_at:
          $ref: "#/components/schemas/Timestamp"
          description: 记录进入 PlayRouter 的时刻（2026-10-12 新增）——records/list 时间窗按它切；改判 / 结算 / 厂商延迟晚到的记录晚于 started_at。
