openapi: 3.1.0
info:
  title: PlayRouter API（方向 A · 运营商调用的端点）
  version: 1.2.0
  summary: 运营商调用 PlayRouter 的 15 个端点：启动游戏、目录、对账、注单、活动管理、彩池 feed。
  description: |
    **你（运营商）是调用方，PlayRouter 是服务方**——与 `playrouter-seamless-wallet-callbacks-v1.openapi.yaml`（方向 B：PlayRouter 调用你的钱包回调）互为镜像，
    双向共用同一组 `pk_/sk_` 密钥。不要把方向 B 的契约（恒 200、transaction_id 幂等）搬到这里：
    方向 A 的幂等键是 body 里的 `idempotency_key`，错误是真 HTTP 码 + 语义字符串错误体。

    ## 接入分层（必须面 = 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 |

    运营商第一天的心智负担：调 4 个端点、实现 5 个回调。其余是"用到再学"的抽屉，不是门槛。

    **上线门槛 = 核心层，仅此而已**：其余端点全部可接可不接——transactions ×2 推荐接（对账），
    campaigns 族发活动才接。每个 operation 的 summary 均已标注【上线必须】/【可选·推荐】/【可选】。

    ## 钱包模式面进路径（商户契约 v2）
    路径第二段是**钱包模式面**：本文件（seamless 钱包）全部端点为 `/v1/seamless/<域>/<动作>`，
    转账钱包产品为 `/v1/transfer/<域>/<动作>`（单段路径同理：`/v1/seamless/currencies` / `/v1/transfer/currencies`）。
    账号开通哪个面才能调用哪个前缀——调用未开通面的前缀返回 **403 `product_not_enabled`**。
    两面共有的 5 个目录/会话端点（sessions/create · sessions/terminate · games/list · providers/list · currencies）
    请求/响应字段**逐字相同**，只是前缀不同（错误码 currency_mismatch 两面判据不同：转账面按玩家钱包的锁定币种，本面按玩家在该厂商用过的币种）。

    ## 全局协议
    - **一律 POST + `application/json; charset=utf-8`**，无 GET；所有端点带 `/v1/seamless` 前缀（见上节）；
      演进只做加法（新增可选字段/枚举值），接收方必须忽略未知字段（前向兼容）。
    - **签名**：双头 HMAC-SHA256（`X-Playrouter-Key-Id` + `X-Playrouter-Signature`，见各端点 parameters）；
      签名串 = `"{t}" + "." + "{raw_body}"`，timestamp 容忍窗 ±300 秒；raw body 逐字节签名。
      验签失败 → **401**（语义正确且唯一）。每环境最多 2 把 active key 并行轮换。
    - **幂等**：变更类端点（campaigns/create、campaigns/cancel）body 必填 `idempotency_key`
      （1–128 字符 `[A-Za-z0-9_.-]`，推荐确定性派生，严禁每次重试新生成）。
      命中重放返回**原响应** + 响应头 `Idempotency-Replayed: true`；同 key 不同 body → 409；保留 7 天。
    - **错误模型**：HTTP 码管重试动作，body 恒为固定形状 ErrorResponse（语义字符串 code，不用数字）。
      调用方只需 4 个分支：2xx 用 body / 429 按 Retry-After 重试 / 5xx 或超时同 idempotency_key 重试 / 其余 4xx 终态记日志等人修。
      未识别的 code 一律按其 HTTP 码的动作处理（code 增补不视为破坏性变更）。完整 29 码目录见规范 §5.2。
    - **200 永远是成功语义**——唯一的"200 里逐项业务失败"是 campaigns/create 的名单 `results`（逐项答复，不是错误）。
      API 永不返回 3xx 重定向。
    - **限流**：标准响应头 `RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset`；超限 429 + `Retry-After`。
    - **金额**一律字符串十进制；**时间**一律 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/seamless/sessions/create:
    post:
      operationId: sessionsCreate
      tags: ["核心（上线必须）"]
      summary: 【上线必须】启动游戏（真钱/试玩合一）
      description: |
        对所有厂商统一先预检你的 `wallet/balance`（消灭厂商特例，你无需感知）。

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

        **demo 模式的 currency = 期望展示币种**：厂商支持试玩币种则用之，不支持则以厂商默认币种（通常 EUR）启动，
        静默降级不报错。real 模式的 currency 是钱包币种，精确匹配、永不降级。同一厂商（多产品线厂商按产品线）内一个 player_id 只能使用一个币种，以首次真钱开局为准（沙箱与正式共用）；换币种开局 → 422 currency_mismatch。
        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/seamless/sessions/terminate:
    post:
      operationId: sessionsTerminate
      tags: ["会话辅助（可选）"]
      summary: 【可选】终止会话（best-effort 踢出）
      description: |
        **尽力而为的踢出扇出，不是强制执行**——能否踢出、踢出后玩家能否重登，效果因厂商而异。
        RG/自我排除的**硬闸门永远在钱包侧**：对 debit 答 `declined(player_blocked)`（实时、资金边界强制、不依赖厂商）。
        terminate 是体验层（把人请出桌），player_blocked 是资金层（让他玩不了）。

        请求给 `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/seamless/games/list:
    post:
      operationId: gamesList
      tags: ["核心（上线必须）"]
      summary: 【上线必须】游戏目录（cursor 分页 + 增量同步）
      description: |
        对象数组 + cursor 分页 + `updated_after` 增量同步。元数据齐备：图、语言、币种、面值、features、rtp、
        启动形态、状态一个对象拿全（消灭独立 Bet/Levels 端点与索引往返——campaigns/create 直接传金额字符串）。

        首次同步：响应头 `X-Full-Dump-Url` 给每日全量快照（CDN URL），万级目录不用翻页；链接下载失败时，改用不带 `updated_after` 的分页全量。
        日常：`updated_after` 只拉变更（厂商下架也是一次 update，`status:"recalled"`；从目录消失的游戏不在增量里，见 Game.status）。

        合规不用自算：不可用的游戏启动时返回 `game_not_available`（地域可用性由 PlayRouter 按厂商牌照判定）；
        平台停用的厂商（产品线）的游戏启动时返回 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/seamless/providers/list:
    post:
      operationId: providersList
      tags: ["核心（上线必须）"]
      summary: 【上线必须】厂商目录（展示名 / 厂商级状态）
      description: 机器码 + 展示名 + 品类 + 币种 + 状态。用途：后台厂商开关页 / lobby 厂商分区展示名（games/list 只含 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/seamless/currencies:
    post:
      operationId: currencies
      tags: ["核心（上线必须）"]
      summary: 【上线必须】币种目录 + 每币种精度
      description: 方向 B 的地基：全 API 金额字符串的小数位数按此表解释——回调金额解析与余额存储精度都依赖它（加密货币不在 ISO 4217 之列），故属上线必须。法币 = ISO 4217 三字母；加密货币 = 3–4 位字母数字，原生单位直接表达（不做 mBTC 型计价换算）。请求为空对象 `{}`。
      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/seamless/transactions/list:
    post:
      operationId: transactionsList
      tags: ["运营（对账）"]
      summary: 【可选·推荐】对账流（cursor，时间窗过滤）
      description: |
        **出身注**：本组端点（list/get）是投递保证的观测面——每日对账兜底、`delivery_status` 投递可见性、
        方向 B 各裁剪声明的"数据去处"（jackpot / event 元数据、回合详情）均承接于此。
        数据源为 PlayRouter 自身账本，非厂商透传。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TransactionsListRequest"
      responses:
        "200":
          description: Transaction 对象分页。
          content:
            application/json:
              schema:
                type: object
                required: [data, has_more]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Transaction"
                  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/seamless/transactions/get:
    post:
      operationId: transactionsGet
      tags: ["运营（对账）"]
      summary: 【可选·推荐】单笔详情（最近 1 个月在线可查 + 投递日志）
      description: |
        按 `transaction_id` 查单笔详情（点查），**最近 1 个月在线可查（更早归档调取，与列表查询窗口同口径）**。
        带投递日志 `delivery_attempts`（像 Stripe webhook attempts）与 jackpot / event / campaign 元数据。
        回合维度查询走 transactions/list 的 `round_id` 过滤（2026-07-15 草案期修订：单对象响应装不下回合多笔，
        get 语义回归点查）。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [transaction_id]
              properties:
                transaction_id:
                  type: string
                  examples: ["txn_01H8XGJWBWK9"]
      responses:
        "200":
          description: 交易详情。
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Transaction"
        "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/seamless/records/list:
    post:
      operationId: recordsList
      tags: ["运营（对账）"]
      summary: 【可选·推荐】游戏记录（注单）流（cursor，按 recorded_at 时间窗）
      description: |
        每一局结束后，把该玩家在这一局的输赢汇成一条记录（部分游戏按手 / 按注给出），供玩家游戏记录、客服查询与按局统计用。
        请求字段、分页、时间窗与转账钱包面的 records/list 逐字相同；可查窗口统一最近 1 个月（from 越界 422）。

        **注单不是对账依据**：资金以钱包回调与 transactions/list 为准。注单只汇总已入账的资金流水（被拒的扣款不计）；
        活动 / 奖励类入账（不挂在某一局上的奖金、返现等）不进注单。

        **出现时机**：局结束后出现，通常几分钟内；部分游戏可能在局结束后数小时才出现，这类游戏的注单自 2026-10-25 起提供。
        部分彩票、体育类游戏目前只给出有派彩的注单，未中奖的注单暂不出现；这类游戏的下注额以 transactions/list 为准。

        **时间窗按 recorded_at 切**、**改判追加冲销 / 更正记录**、**金额求和只算 status=settled**——三条与转账钱包面 records/list 同一套规则。
        **粒度因游戏类型而异**：多数游戏一局一条（多人同桌每位玩家各一条）；进桌带入、离桌带出的桌游每手一条；每注独立结算的游戏每注一条
        （未结算先给 unsettled 快照，结算后另追加 settled）。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RecordsListRequest"
      responses:
        "200":
          description: Record 对象分页。
          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" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /v1/seamless/records/get:
    post:
      operationId: recordsGet
      tags: ["运营（对账）"]
      summary: 【可选·推荐】单笔游戏记录（客诉单查）
      description: |
        按 record_id 查单笔游戏记录，最近 1 个月在线可查（更早归档调取，与列表查询窗口同口径）。
        查无此记录（含不属于你的记录）→ 404 record_not_found。
      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: 预留（详情页语言）；目前无详情页，可省略。
      responses:
        "200":
          description: 单笔记录。
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Record"
        "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/seamless/campaigns/create:
    post:
      operationId: campaignsCreate
      tags: ["活动模块（可选，启用才认证）"]
      summary: 【可选·活动模块】创建免费游戏活动（变更类，带幂等键）
      description: |
        单一活动模型收编三种赠品：`type: freespin | freechip | bonus_buy`——公共骨架字段按 type 取语义
        （freespin：bet_amount=单次旋转投注额、free_game_count=旋转次数；freechip：单枚筹码面值 × 枚数；
        bonus_buy：功能购买基础注 × 固定 1 次，free_game_count 可省略）。
        类型/厂商专属差异一律进 `provider_options`，按 campaigns/options 返回的 (provider, type) JSON Schema 校验。

        **名单内嵌**：活动 = 一次性名单快照——`player_ids` 随 create 提交（≤1000/次），
        逐玩家成败在响应 `results` 给出（整批 2xx，单人失败不连坐）；>1000 人或中途追加 = 另建活动。

        **总量语义**：`free_game_count` 为整个活动的总量——玩家可在 `game_codes` 内任意游戏消耗，不是每游戏各一份。

        **响应仍须持久化**：campaign_id 是 cancel 的唯一引用键；响应丢失时 7 天内可凭同 idempotency_key 重放找回，超期经 campaigns/list 定位 + campaigns/get 取回全量（v1.x 加法——查询面是兜底，不是持久化义务的替代）。

        厂商约束（时长上限等）创建前可经 campaigns/options 的 `provider_data.constraints` 自查；
        违规创建 → 422 `validation_failed` + `fields` 明细。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CampaignsCreateRequest"
      responses:
        "200":
          description: 活动已创建（初始 pending）+ 名单逐项 results。
          headers:
            Idempotency-Replayed:
              description: 幂等命中重放原响应时为 true。
              schema:
                type: boolean
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CampaignsCreateResponse"
        "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/seamless/campaigns/cancel:
    post:
      operationId: campaignsCancel
      tags: ["活动模块（可选，启用才认证）"]
      summary: 【可选·活动模块】取消活动（变更类，带幂等键）
      description: |
        **取消承诺**：`pending` 状态必可取消（尚未到厂商，PlayRouter 自己说了算）；
        `started` 取消受理即回执终态 `cancelled`，厂商侧未使用发放的撤回由平台后台异步完成（撤回失败自动重试；发放随活动截止时刻自然失效兜底）。
        422 `campaign_state_invalid` 只余两种形态：活动已 `ended`；或厂商没有撤回能力时取消 `started` 活动
        （厂商级"不可取消"能力可经 campaigns/options 的 `provider_data.constraints.cancellable` 预先自查）。
        对已取消的活动再次取消，返回同一终态回执 `cancelled`，不报错。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [idempotency_key, campaign_id]
              properties:
                idempotency_key:
                  $ref: "#/components/schemas/IdempotencyKey"
                campaign_id:
                  type: string
                  description: create 响应发的 cmp_（全局唯一，无需厂商辅助定位）。
                  examples: ["cmp_01H8XGJWBW"]
      responses:
        "200":
          description: 取消成功，回执带终态。
          headers:
            Idempotency-Replayed:
              description: 幂等命中重放原响应时为 true。
              schema:
                type: boolean
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [campaign_id, status]
                    properties:
                      campaign_id:
                        type: string
                        examples: ["cmp_01H8XGJWBW"]
                      status:
                        $ref: "#/components/schemas/CampaignStatus"
        "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/seamless/campaigns/options:
    post:
      operationId: campaignsOptions
      tags: ["活动模块（可选，启用才认证）"]
      summary: 【可选·活动模块】厂商参数发现（JSON Schema + 数据区）
      description: |
        按 (provider, type) 组合返回：`json_schema` 管"怎么填合法"（SDK 请求前本地校验 provider_options），
        `provider_data` 管"填哪个划算/可用"（数据非 schema 的发现信息——bonus_buy 的逐游戏成本表 cost_multipliers、
        freechip 的动态面值 chip_values、通用 constraints（max_duration_days / cancellable 等 JSON Schema 表达不了的约束））。

        `data` 恒为数组：指定 type = 单元素，缺省 = 该厂商全部 type 逐项。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider]
              properties:
                provider:
                  type: string
                  description: 厂商代号（来自本面 providers/list 的 code）。
                  examples: ["pg"]
                type:
                  $ref: "#/components/schemas/CampaignType"
      responses:
        "200":
          description: (provider, type) 逐项的 schema + 数据区。
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/CampaignOptions"
        "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/seamless/campaigns/list:
    post:
      operationId: campaignsList
      tags: ["活动模块（可选，启用才认证）"]
      summary: 【可选·活动模块】活动列表（只读，cursor 分页 + 过滤）
      description: |
        活动查询面之一（v1.x 纯加法，2026-07-09）：cursor 分页 + status / type / provider / 创建时间窗过滤，
        返回活动骨架 CampaignSummary（不含逐玩家 results——全量用 campaigns/get）。
        典型用途：create 响应丢失且幂等超期后按名称/时间找回 campaign_id；营销后台列表页。只读、无幂等键。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                cursor:
                  type: string
                  description: 上页 next_cursor 原样带回。
                limit:
                  type: integer
                  minimum: 1
                  maximum: 100
                  default: 20
                status:
                  $ref: "#/components/schemas/CampaignStatus"
                type:
                  $ref: "#/components/schemas/CampaignType"
                provider:
                  type: string
                  description: 可选过滤。厂商代号（来自本面 providers/list 的 code）。
                  examples: ["pg"]
                from:
                  $ref: "#/components/schemas/Timestamp"
                  description: 创建时间窗起（按 created_at 过滤）。
                to:
                  $ref: "#/components/schemas/Timestamp"
                  description: 创建时间窗止。
      responses:
        "200":
          description: CampaignSummary 对象分页。
          content:
            application/json:
              schema:
                type: object
                required: [data, has_more]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/CampaignSummary"
                  next_cursor:
                    type: ["string", "null"]
                  has_more:
                    type: boolean
        "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/seamless/campaigns/get:
    post:
      operationId: campaignsGet
      tags: ["活动模块（可选，启用才认证）"]
      summary: 【可选·活动模块】单个活动全量（只读）
      description: |
        按 campaign_id 查单个活动全量（v1.x 纯加法，2026-07-09）：create 请求回显 + 当前 status +
        名单逐玩家 results（与 create 响应同构，反映发放时点结果）。
        查无此单或不属于你 → 404 campaign_not_found（防枚举，越权不可分辨）。只读、无幂等键。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [campaign_id]
              properties:
                campaign_id:
                  type: string
                  description: create 响应发的 cmp_（或经 campaigns/list 找回）。
                  examples: ["cmp_01H8XGJWBW"]
      responses:
        "200":
          description: 活动全量。
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Campaign"
        "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/seamless/jackpots/feed:
    post:
      operationId: jackpotsFeed
      tags: ["展示 / 参考（可选）"]
      summary: 【可选】彩池当前金额（只读，缓存数据）
      description: |
        彩池当前金额的只读缓存 feed。典型用途：lobby 彩池金额展示（跑马灯等）——数据用途不设限，
        唯一硬约束：**非结算依据**（派彩以方向 B 的 credit(source=jackpot) 回调为准）。
        单币种查询（amount 以请求币种计，换币种再查）。
        `pool_id` 与 Transaction.jackpot.breakdown[].pool_id 同一命名空间——feed、派彩、对账三处对得上。
        缓存语义：`updated_at` 为数据时刻，TTL 分钟级。覆盖范围随厂商逐步扩展；无数据的厂商游戏不出现在响应中。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [currency]
              properties:
                currency:
                  $ref: "#/components/schemas/CurrencyCode"
                provider:
                  type: ["string", "null"]
                  description: 可选过滤。厂商代号（来自本面 providers/list 的 code）。
                  examples: ["pp"]
                game_code:
                  type: ["string", "null"]
                  description: 可选过滤。`<provider>:<游戏段>` 不透明键，与 games/list 的 game_code 同一把键。
                  examples: ["pp:jackpot-game"]
      responses:
        "200":
          description: 逐游戏彩池条目（amount 以请求币种计）。
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/JackpotFeedEntry"
        "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" }

components:
  parameters:
    KeyId:
      name: X-Playrouter-Key-Id
      in: header
      required: true
      description: 使用的密钥 ID（pk_live_… / pk_test_…，与方向 B 同一组）。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 / product_not_enabled / ip_not_allowed）——联系配置；ip_not_allowed 为可选的来源 IP 白名单拒绝，把出口 IP 加入 console 名单即可。provider_not_enabled 不是账号配置问题：本面当前不提供该厂商（或产品线）——未接入本面，或已被平台停用；以本面 providers/list 为准。feature_not_enabled：campaigns/options 对某厂商回空数组即该厂商暂不支持活动发放，此时建活动返回 403 feature_not_enabled——不是你的配置问题，等我方通知开通即可。"
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    NotFound:
      description: "404：路径不存在，或引用的资源不存在（not_found / game_not_found / campaign_not_found / transaction_not_found / session_not_found / record_not_found）——含存在但不属于你的资源（越权响应不可分辨，防枚举）。修引用。"
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    IdempotencyConflict:
      description: |
        409 两个码按 code 分流：idempotency_key_reused——同 key 不同 body（永久错误，修 bug，绝不执行）；
        request_in_progress——同 key 同 body、首请求仍在处理中（瞬态）：按 Retry-After 秒数用同一 key 重试，
        届时命中重放取回原响应。判定顺序 = 先比 body、再看状态。适用于所有带 idempotency_key 的变更端点
        （transfers/create 例外：撞键在途返 200 + pending 单据）。
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
        Retry-After:
          description: 仅 request_in_progress 时出现——建议的重试等待秒数（值不承诺稳定）。
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    ValidationFailed:
      description: "422：字段校验失败（validation_failed / currency_not_supported / currency_mismatch / game_not_available / demo_not_available / provider_options_invalid / campaign_state_invalid）。修请求；fields 给逐字段明细。例外：real 启动遇该币种平台侧暂时不可用时也返 currency_not_supported，fields.currency = temporarily unavailable（固定值，可按它程序判断）——不要换币种，稍后重试同一请求。sessions/create 遇平台侧暂时不可用时返 game_not_available，fields.game_code = temporarily unavailable（固定值）——游戏没有下架，不要从大厅撤下，稍后重试同一请求；不带该标记的 game_not_available（含厂商拒绝该玩家进入）重试不会成功。currency_mismatch = 该玩家在这个厂商已使用其他币种（同一厂商内一个 player_id 一个币种，message 点名该币种，按它开局或按币种拆 player_id）。"
      headers:
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    RateLimited:
      description: "429：rate_limited。按 Retry-After 重试。records/list 的下限配额为本面与转账钱包面两面合计。"
      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 为预留码，目前不会出现）。幂等保障下可安全重试（变更类同 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: 金额一律字符串十进制，永远非负。小数位按 /v1/seamless/currencies 公布的每币种 decimals 写满，零也一样（2 位币种写 "0.00"）。请按十进制数值解析与比较，不要按字符串比较。
      examples: ["10.00", "0.50", "0.00"]

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

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

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

    ErrorResponse:
      type: object
      required: [error]
      description: 每个 4xx/5xx 的固定错误体（唯一例外：基础设施层 5xx 可能非 JSON）。完整 29 码目录见规范 §5.2。
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code:
              type: string
              description: 语义字符串错误码（不用数字）。未识别的 code 按其 HTTP 码的动作处理。
              examples: ["game_not_available"]
            message:
              type: string
              description: 人读描述（只进日志，不要求逐个 catch）。
              examples: ["Game 'pg:fortune-tiger' is not enabled for your operator."]
            doc_url:
              type: string
              description: 该错误码的文档页。
              examples: ["https://docs.playrouter.io/errors/game_not_available"]
            request_id:
              type: string
              description: 请求追踪 ID（同响应头 X-Request-Id）。
              examples: ["req_01H8XGJWBW"]
            fields:
              type: object
              description: 逐字段校验明细（422 时给）。
              additionalProperties:
                type: string
              examples:
                - { game_code: "not enabled" }

    SessionsCreateRequest:
      type: object
      required: [game_code, currency, mode]
      properties:
        player_id:
          type: string
          maxLength: 64
          description: 运营商侧稳定玩家标识。real 必填；demo 可选（传了可做统一日志归因）。全 API 唯一的玩家身份面——不收 PII。
          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 = 钱包币种（精确匹配永不降级）。同一厂商（多产品线厂商按产品线）内一个 player_id 只能使用一个币种，以首次真钱开局为准（沙箱与正式共用）；换币种开局 → 422 currency_mismatch。
        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: 可选透传串，方向 B 回调原样带回。
          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 启动文档，从你的自有域名伺服。
            伺服要求：每次启动重新调用 sessions/create 取新 html、响应带 `Cache-Control: no-cache, no-store, must-revalidate`——
            厂商侧启动令牌一次性，缓存复用会启动失败。
          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` 同一命名空间。产品维度以本字段为准，不要自行拆 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 该传什么币种；实例真正接受的币种以 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: 可选（不一定有）。币种 → 合法投注面值（金额字符串数组），只在厂商提供面值表时出现；缺省 = 厂商不给，campaigns/create 的 bet_amount 按厂商文档自定——无索引、无独立端点。
          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（与 recalled 对称的可用性约束，"预告上架"排期数据源）。
        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` 取值发现；`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]

    TransactionsListRequest:
      type: object
      description: 时间窗（from/to）**不带 round_id 时必填**；带 round_id 时可省略（回合天然有界）——条件必填由 422 执行。
      properties:
        from:
          $ref: "#/components/schemas/Timestamp"
          description: 时间窗起点（按 occurred_at 过滤）；不带 round_id 时必填。
        to:
          $ref: "#/components/schemas/Timestamp"
          description: 时间窗终点；不带 round_id 时必填。
        round_id:
          type: ["string", "null"]
          description: 可选——按回合过滤（rnd_ 前缀；2026-07-15 草案期修订，原 transactions/get 的回合查询挪到此处）。回合内多笔按 sequence 排序即回合全貌。
          examples: ["rnd_01H8XGJWBW"]
        cursor:
          type: ["string", "null"]
          description: 上页响应的 next_cursor；首页省略或传 null。黑盒游标：内容不作承诺，原样传回即可。
        limit:
          type: integer
          minimum: 1
          maximum: 5000
          examples: [2000]
        player_id:
          type: ["string", "null"]
          maxLength: 64
          description: 可选过滤。
        provider:
          type: ["string", "null"]
          description: 可选过滤。厂商代号（来自本面 providers/list 的 code）。
          examples: ["pg"]

    Transaction:
      type: object
      description: PlayRouter 账本的交易对象——方向 B 回调的对账镜像 + 投递日志 + 裁剪数据去处（jackpot / event 元数据）。
      required: [transaction_id, type, player_id, currency, occurred_at, delivery_status]
      properties:
        transaction_id:
          type: string
          examples: ["txn_01H8XGJWBWK9"]
        type:
          type: string
          enum: [debit, credit, settle, rollback]
        source:
          type: string
          description: 资金语义标签（与方向 B source 同一枚举族）。
          examples: ["game", "bet", "game_win", "freespin_payout", "jackpot"]
        player_id:
          type: string
          maxLength: 64
          examples: ["wang-123"]
        currency:
          $ref: "#/components/schemas/CurrencyCode"
        bet:
          $ref: "#/components/schemas/Money"
        win:
          $ref: "#/components/schemas/Money"
        round_id:
          type: string
          examples: ["rnd_01H8XGJWBW"]
        sequence:
          type: integer
          minimum: 1
          description: 回合内单调递增序号。
        round_finished:
          type: boolean
        game_code:
          type: string
          description: 游戏键 `<provider>:<游戏段>`（不透明键，与 games/list 同一把）。
          examples: ["pg:fortune-tiger", "db:card-101"]
        provider:
          type: string
          description: 厂商代号（来自本面 providers/list 的 code）。
          examples: ["pg"]
        provider_product:
          type: string
          description: 可选回显。厂商产品维度键（多产品线厂商时出现），值域同 games/list 的 `provider_product` / providers/list 的 `products[].code`。
          examples: ["card"]
        session_id:
          type: string
          examples: ["ses_01H8XGJWBW"]
        occurred_at:
          $ref: "#/components/schemas/Timestamp"
        jackpot:
          type: object
          description: 可选——厂商给了彩池明细才出现（钱包回调不带，数据去处在此）。
          properties:
            contribution:
              $ref: "#/components/schemas/Money"
            win:
              $ref: "#/components/schemas/Money"
            breakdown:
              type: array
              items:
                type: object
                required: [pool_id]
                properties:
                  pool_id:
                    type: string
                    description: 与 jackpots/feed 的 pools[].pool_id 同一命名空间。
                    examples: ["grand"]
                  contribution:
                    $ref: "#/components/schemas/Money"
                  win:
                    $ref: "#/components/schemas/Money"
        campaign_id:
          type: string
          description: 可选——资金源于运营商活动时出现（与回调内同值）。
          examples: ["cmp_01H8XGJWBW"]
        event:
          type: object
          description: 可选——厂商侧活动明细（锦标赛/奖品雨；钱包回调不带，数据去处在此）。
          properties:
            id:
              type: string
              examples: ["evt-prov-881"]
            type:
              type: string
              examples: ["Tournament"]
            details:
              type: object
              additionalProperties: true
        delivery_status:
          type: string
          enum: [pending, delivered, retrying, frozen, failed]
          description: 方向 B 投递状态（§1.8 承诺的观测面）。frozen = 端点返回 3xx 跳转或非 429 的 4xx（配置故障），已暂停自动重试并告警、修复确认后恢复（v1.x 加法新增值）。
        delivery_attempts:
          type: array
          description: 投递日志（像 Stripe webhook attempts）。
          items:
            type: object
            properties:
              attempted_at:
                $ref: "#/components/schemas/Timestamp"
              http_status:
                type: ["integer", "null"]
              outcome:
                type: string
                examples: ["timeout", "ok"]
        operator_transaction_id:
          type: string
          description: 运营商响应回执的账目 ID（有则记录）。
          examples: ["op-ledger-991"]

    SignedMoney:
      type: string
      pattern: '^-?\d{1,18}(\.\d{1,12})?$'
      description: 带符号金额字符串——仅 records 的冲销记录（source=adjustment 且带 ref_record_id 的行）取负值；其余一切金额恒非负（同 Money）。对 status=settled 的记录求和净额天然正确。
      examples: ["-10.00", "10.00"]

    RecordsListRequest:
      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
        provider:
          type: string
          description: 厂商族码（来自本面 providers/list 的 code）。
          examples: ["pg"]

    Record:
      type: object
      required: [record_id, player_id, provider, game_code, category, currency, bet, win, source, status, started_at, recorded_at]
      description: |
        游戏记录对象（本面 records/list 与 records/get 同一形状）。字段名与转账钱包面 Record 相同；
        **round_id 与 valid_bet 在本面不一定有、含义与转账面不同**（见各字段）。注单不是资金对账依据。
      properties:
        record_id:
          type: string
          description: 记录唯一 ID（rec_ 前缀）——跨页去重主键、records/get 查询键。
          examples: ["rec_01H8XGJWBW"]
        ref_record_id:
          type: string
          description: 可选。冲销 / 更正记录指向被冲销原记录的 record_id。
        player_id:
          type: string
          maxLength: 64
        provider:
          type: string
          examples: ["pg"]
        provider_product:
          type: string
          description: 可选回显。厂商产品维度键（多产品线厂商时出现）。
          examples: ["card"]
        game_code:
          type: string
          examples: ["pg:fortune-tiger", "db:card-101"]
        category:
          type: string
          description: 品类，值域同 games/list；查不到时为空串（不编）。键恒在。
        currency:
          $ref: "#/components/schemas/CurrencyCode"
        round_id:
          type: string
          description: |
            可选。回合 ID（rnd_）——与钱包回调的 round.id、transactions/list 的 round_id 同值；
            用它加 player_id 调 transactions/list 即得本行涉及的资金流水（本行 bet / win 只计其中已入账的）。
            不按局结算的游戏（进桌带入、离桌带出的桌游；每注独立结算的电竞）不带。
            **注意：转账钱包面同名字段是厂商侧回合标识，两面含义不同。**
          examples: ["rnd_01H8XGJWBW"]
        bet:
          $ref: "#/components/schemas/SignedMoney"
        valid_bet:
          $ref: "#/components/schemas/SignedMoney"
          description: 可选。有效投注额——只有游戏本身给出了有效投注时才出现，拿不到不给（不以投注额冒充）。
        win:
          $ref: "#/components/schemas/SignedMoney"
        source:
          type: string
          description: "`game` / `adjustment`（冲销 / 更正）；可按加法增补。"
          examples: ["game"]
        status:
          type: string
          enum: [unsettled, settled, canceled, refunded]
          description: 与转账钱包面同一口径——金额求和只算 settled；canceled / refunded 金额保留原注额。
        provider_record_id:
          type: string
          description: 可选。厂商侧单号——客诉排障时报给我们。
          examples: ["8865512900"]
        started_at:
          $ref: "#/components/schemas/Timestamp"
        settled_at:
          $ref: "#/components/schemas/Timestamp"
          description: 结束 / 结算时间；unsettled 时缺省。
        recorded_at:
          $ref: "#/components/schemas/Timestamp"
          description: 记录进入 PlayRouter 的时刻——records/list 时间窗按它切。

    CampaignType:
      type: string
      enum: [freespin, freechip, bonus_buy]
      description: 与方向 B 派彩 source 一一对称——freespin↔freespin_payout、freechip↔freechip_payout、bonus_buy↔bonus_buy_payout。

    CampaignStatus:
      type: string
      enum: [pending, started, ended, cancelled]
      description: 经 create（初始 pending）、cancel（终态 cancelled）及 get / list（v1.x 加法，2026-07-09）的响应可见。

    CampaignsCreateRequest:
      type: object
      required: [idempotency_key, type, name, provider, game_codes, currency, bet_amount, starts_at, ends_at, player_ids]
      properties:
        idempotency_key:
          $ref: "#/components/schemas/IdempotencyKey"
        type:
          $ref: "#/components/schemas/CampaignType"
        name:
          type: string
          description: 活动名——create 回执与对账日志里的可读标签。
          examples: ["vip-july-freespins"]
        provider:
          type: string
          description: 厂商代号（来自本面 providers/list 的 code），显式单列（所有 game_codes 须属同一厂商，校验更早）。
          examples: ["pg"]
        game_codes:
          type: array
          minItems: 1
          description: 游戏键数组（`<provider>:<游戏段>` 不透明键，取自 games/list）。
          items:
            type: string
          examples: [["pg:fortune-tiger"]]
        currency:
          $ref: "#/components/schemas/CurrencyCode"
        free_game_count:
          type: integer
          minimum: 1
          description: |
            **整个活动的总量**（game_codes 内任意游戏消耗，不是每游戏各一份）。
            语义按 type：freespin=旋转次数（必填）；freechip=筹码枚数（必填）；
            bonus_buy=固定 1，可省略。条件必填由 422 校验执行。
          examples: [10]
        bet_amount:
          $ref: "#/components/schemas/Money"
          description: 语义按 type：freespin=单次旋转投注额；freechip=单枚筹码面值（须 ≥ 游戏最小注，厂商侧校验）；bonus_buy=功能购买基础注（成本 = 基础注 × 厂商 cost_multiplier）。从 games/list 的 bet_levels 选值——金额→厂商索引翻译由 PlayRouter 转换层承担。
        starts_at:
          $ref: "#/components/schemas/Timestamp"
        ends_at:
          $ref: "#/components/schemas/Timestamp"
        player_ids:
          type: array
          minItems: 1
          maxItems: 1000
          description: 发放名单内嵌（一次性名单快照；>1000 人或中途追加 = 另建活动）。逐玩家成败在响应 results 给出。
          items:
            type: string
          examples: [["wang-123", "li-456"]]
        provider_options:
          type: object
          description: 类型/厂商专属参数（max_win、bonus_type、multiplier 等），按 campaigns/options 返回的 (provider, type) JSON Schema 校验；不合 schema → 422 provider_options_invalid。
          additionalProperties: true
          examples:
            - { multiplier: 10 }

    CampaignsCreateResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [campaign_id, status, results]
          properties:
            campaign_id:
              type: string
              description: cancel 的唯一引用键——**须持久化**（丢失时 7 天内可凭同 idempotency_key 重放找回，超期经 campaigns/list / get 找回）。
              examples: ["cmp_01H8XGJWBW"]
            status:
              $ref: "#/components/schemas/CampaignStatus"
            results:
              type: array
              description: 名单逐玩家答复（整批 2xx，单人失败不连坐；这是逐项答复，不是错误）。
              items:
                type: object
                required: [player_id, status]
                properties:
                  player_id:
                    type: string
                    maxLength: 64
                    examples: ["wang-123"]
                  status:
                    type: string
                    enum: [ok, failed]
                  code:
                    type: string
                    description: failed 时给。开放枚举：player_id_invalid（格式不合法，受理校验；预留码——目前名单含不合法 ID 时整单回 422 validation_failed）· campaign_player_limit_reached（活动人数达厂商上限）· provider_rejected（厂商拒绝向该玩家发放，投放后才知道，经 campaigns/get 查看）。create 首次应答只反映受理校验，厂商侧逐玩家结果在投放后经 campaigns/get 给出。
                    examples: ["provider_rejected"]

    CampaignSummary:
      type: object
      description: 活动骨架（campaigns/list 的分页项）——不含逐玩家 results，全量用 campaigns/get。v1.x 纯加法（2026-07-09）。
      required: [campaign_id, name, type, provider, status, currency, starts_at, ends_at, created_at, player_count]
      properties:
        campaign_id:
          type: string
          examples: ["cmp_01H8XGJWBW"]
        name:
          type: string
          examples: ["vip-july-freespins"]
        type:
          $ref: "#/components/schemas/CampaignType"
        provider:
          type: string
          description: 厂商代号（来自本面 providers/list 的 code）。
          examples: ["pg"]
        status:
          $ref: "#/components/schemas/CampaignStatus"
        currency:
          $ref: "#/components/schemas/CurrencyCode"
        starts_at:
          $ref: "#/components/schemas/Timestamp"
        ends_at:
          $ref: "#/components/schemas/Timestamp"
        created_at:
          $ref: "#/components/schemas/Timestamp"
          description: create 受理时刻（本次加法新增字段，list / get 同出）。
        player_count:
          type: integer
          description: 名单人数（逐玩家明细经 campaigns/get 的 results）。
          examples: [2]

    Campaign:
      description: 单个活动全量（campaigns/get）——create 请求回显 + 当前状态 + 逐玩家 results。v1.x 纯加法（2026-07-09）。
      allOf:
        - $ref: "#/components/schemas/CampaignSummary"
        - type: object
          required: [game_codes, bet_amount, results]
          properties:
            game_codes:
              type: array
              items:
                type: string
              examples: [["pg:fortune-tiger"]]
            bet_amount:
              $ref: "#/components/schemas/Money"
            free_game_count:
              type: integer
              description: 随 create 传否（bonus_buy 可省略）。
            provider_options:
              type: object
              additionalProperties: true
              description: 随 create 传否，原样回显。
            results:
              type: array
              description: 名单逐玩家答复（与 create 响应同构，反映发放时点结果）。
              items:
                type: object
                required: [player_id, status]
                properties:
                  player_id:
                    type: string
                    maxLength: 64
                  status:
                    type: string
                    enum: [ok, failed]
                  code:
                    type: string

    CampaignOptions:
      type: object
      required: [provider, type, json_schema]
      properties:
        provider:
          type: string
          description: 厂商代号（来自本面 providers/list 的 code）。
          examples: ["pg"]
        type:
          $ref: "#/components/schemas/CampaignType"
        json_schema:
          type: object
          description: 该 (provider, type) 下 provider_options 的 JSON Schema——SDK 请求前本地校验；值域类信息（bonus_type 枚举、Playtech 面值枚举）经 enum 承载。
          additionalProperties: true
        provider_data:
          type: ["object", "null"]
          description: 数据类发现信息（"填哪个划算/可用"），无数据时为 null。
          properties:
            cost_multipliers:
              type: array
              description: type=bonus_buy——逐游戏成本表（发放成本 = bet_amount × multiplier）。
              items:
                type: object
                required: [game_code, bonus_type, multiplier]
                properties:
                  game_code: { type: string }
                  bonus_type: { type: string }
                  multiplier: { type: number }
            chip_values:
              type: object
              description: type=freechip——动态面值厂商按币种的合法面值数组。
              additionalProperties:
                type: array
                items:
                  $ref: "#/components/schemas/Money"
            constraints:
              type: object
              description: 通用厂商约束（JSON Schema 表达不了的跨字段/能力类限制）——创建前自查，违规创建 422 + fields 明细。
              properties:
                max_duration_days:
                  type: integer
                  description: 活动最长时长（天）。
                cancellable:
                  type: boolean
                  description: 厂商级"可否取消"能力预查。
              additionalProperties: true

    JackpotFeedEntry:
      type: object
      required: [game_code, provider, pools, updated_at]
      properties:
        game_code:
          type: string
          description: 游戏键 `<provider>:<游戏段>`（不透明键，与 games/list 同一把）。
          examples: ["pp:jackpot-game"]
        provider:
          type: string
          description: 厂商代号（来自本面 providers/list 的 code）。
          examples: ["pp"]
        pools:
          type: array
          items:
            type: object
            required: [pool_id, amount]
            properties:
              pool_id:
                type: string
                description: 与 Transaction.jackpot.breakdown[].pool_id 同一命名空间。
                examples: ["grand"]
              amount:
                $ref: "#/components/schemas/Money"
                description: 以请求币种计。
        updated_at:
          $ref: "#/components/schemas/Timestamp"
          description: 数据时刻（缓存，TTL 分钟级）；非结算依据（派彩以 credit(source=jackpot) 回调为准）。
