openapi: 3.1.0
info:
  title: PlayRouter Wallet Callbacks（方向 B · 运营商实现的钱包回调）
  version: 1.0.1
  summary: 运营商需要实现的 5 个钱包回调端点（全部属上线必须面）。PlayRouter 调用，运营商应答。
  description: |
    这是**你（运营商）要实现、PlayRouter 来调用**的 API——方向与常见 OpenAPI 相反。
    你在后台注册一个 base URL，PlayRouter 向 `{base}/wallet/*` 五个固定路径发送 POST。

    ## 三条铁律（认证套件逐条验证）
    1. **credit 和 rollback 不允许 declined**（唯二例外 player_not_found / currency_mismatch——账本无法定位、拒收合理，PlayRouter 收到即进对账队列 + 双方告警，不重试不丢弃；用户裁决 2026-07-08）——余额类原因（含余额不足）必须执行，允许余额为负，运营商自行风控追偿。无法处理（内部故障）返回 5xx，PlayRouter 按重试时间表重试。
    2. **不得因会话过期拒绝任何资金回调**——合法性只由签名决定。会话真伪由 PlayRouter 在厂商边界校验完成。
    3. **未知的 source 枚举值不得拒绝**——按普通资金变动入账（前向兼容）。

    ## 投递保证（PlayRouter 对你的书面承诺）
    1. 绝不对你未成功收到的交易发 rollback（厂商乱序由 PlayRouter 吸收）。
    2. rollback 之后绝不再投递原始交易（墓碑由 PlayRouter 维护）。
    3. 引用完整性 + 回合内 FIFO：回调引用的任何 transaction_id 保证已成功送达过你；
       同一 round.id 的回调串行投递（前一条 2xx 后才发下一条），并带回合内单调递增 round.sequence。
    收口不变量：每个回合最终恰好一次 `round.finished: true`（随最后一笔资金捎带，或零额 round_finish 补投）。冲正不受收口约束：收口后仍可能到达迟到的 rollback（更正流），不改变收口状态、不携带 finished:true。

    ## 重试时间表（5xx / 超时后）
    5s → 1m → 5m → 1h → 6h → 12h → 24h（共 8 次尝试，响应超时 10s）。
    3xx 与 429 以外的 4xx = 配置故障：告警 + 冻结投递，不重试轰炸（我们不跟随跳转）；429 按 5xx 进重试时间表。
    全部失败 → 对账队列 + 双方告警 + transactions/* 可见（delivery_status: failed）。
  contact:
    name: PlayRouter Docs
    url: https://docs.playrouter.io
servers:
  - url: "https://{operator_base}"
    description: 运营商在 PlayRouter 后台注册的回调 base URL（沙箱与生产各自独立注册）。须为最终地址：PlayRouter 不跟随 HTTP 3xx 跳转，收到 3xx 按配置故障处理（同非 429 的 4xx：冻结投递并告警）。
    variables:
      operator_base:
        default: wallet.example-operator.com

paths:
  /wallet/balance:
    post:
      operationId: walletBalance
      summary: 【上线必须】查余额（只读，无幂等键）
      description: |
        返回玩家当前余额。游戏启动预检与游戏内余额刷新都会调用。
        只读端点：没有 transaction_id，不需要幂等处理。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BalanceRequest"
      responses:
        "200":
          description: 成功（ok）或业务拒绝（declined，允许 player_not_found / currency_mismatch）或验签失败（error）
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/OkResponse"
                  - $ref: "#/components/schemas/DeclinedResponse"
                  - $ref: "#/components/schemas/ErrorResponse"

  /wallet/debit:
    post:
      operationId: walletDebit
      summary: 【上线必须】纯扣款（可 declined）
      description: |
        从玩家钱包扣款。方向恒为扣，金额恒非负。业务上无法扣（余额不足、玩家被禁投等）
        返回 200 + status:"declined"——这是合法答复，不是错误。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DebitRequest"
      responses:
        "200":
          description: ok / declined / error
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/OkResponse"
                  - $ref: "#/components/schemas/DeclinedResponse"
                  - $ref: "#/components/schemas/ErrorResponse"

  /wallet/credit:
    post:
      operationId: walletCredit
      summary: 【上线必须】纯入账（仅 player_not_found / currency_mismatch 可 declined）
      description: |
        向玩家钱包入账。**铁律①：余额类原因不允许 declined（唯二例外 player_not_found / currency_mismatch，见全局说明）**——免费游戏、促销、彩池、零额收口
        全走此端点，source 字段区分语义；未知 source 照收（铁律③）。
        `source: "round_finish"` 为零额回合收口专用（amount 为零，如 "0.00"；余额不变）。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreditRequest"
      responses:
        "200":
          description: ok / declined（仅 player_not_found / currency_mismatch）/ error
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/OkResponse"
                  - $ref: "#/components/schemas/ErrorResponse"

  /wallet/settle:
    post:
      operationId: walletSettle
      summary: 【上线必须】扣款 + 派彩一次完成（合笔结算型厂商专用，非主流形态）
      description: |
        单个 DB 事务内：扣 bet、加 win、返回最新余额。扣款部分不足 → 整体 declined。
        输了的回合照常发（win:"0.00"），round.finished:true 随此笔捎带。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SettleRequest"
      responses:
        "200":
          description: ok / declined / error
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/OkResponse"
                  - $ref: "#/components/schemas/DeclinedResponse"
                  - $ref: "#/components/schemas/ErrorResponse"

  /wallet/rollback:
    post:
      operationId: walletRollback
      summary: 【上线必须】冲正（余额可为负；仅 player_not_found / currency_mismatch 可 declined）
      description: |
        撤销原交易的余额影响：原为扣则退、原为入则收回。
        **铁律①：余额类原因不允许 declined（唯二例外见全局说明）**，余额可为负，运营商自行风控追偿。
        承诺①②：original_transaction_id 必然是你成功处理过的交易；rollback 后原交易绝不会再来。
      parameters:
        - $ref: "#/components/parameters/KeyId"
        - $ref: "#/components/parameters/Signature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RollbackRequest"
      responses:
        "200":
          description: ok / declined（仅 player_not_found / currency_mismatch）/ error
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/OkResponse"
                  - $ref: "#/components/schemas/ErrorResponse"

components:
  parameters:
    KeyId:
      name: X-Playrouter-Key-Id
      in: header
      required: true
      description: 使用的密钥 ID（pk_live_… / pk_test_…）。按它选择对应 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) 的十六进制小写。
        必须校验 |now - t| ≤ 300 秒（防重放）。验签失败返回 200 + {"status":"error","reason":"invalid_signature"}。
      schema:
        type: string
        examples: ["t=1719792000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"]

  schemas:
    Money:
      type: string
      pattern: '^\d{1,18}(\.\d{1,12})?$'
      description: 金额一律字符串十进制，永远非负（方向由端点表达）。小数位按 /v1/seamless/currencies 公布的每币种 decimals 写满，零也一样（2 位币种写 "0.00"）。请按十进制数值解析与比较，不要按字符串比较。
      examples: ["10.00", "0.000001", "0.00"]

    BalanceValue:
      type: string
      pattern: '^-?\d{1,18}(\.\d{1,12})?$'
      description: 余额。钱包回调里唯一允许负号的金额字段——仅在 credit/rollback 强制执行（铁律①）导致余额为负后出现。
      examples: ["114.50", "-95.00"]

    Round:
      type: object
      description: 回合上下文（有回合语境时给）。
      required: [id, sequence, finished]
      properties:
        id:
          type: string
          description: PlayRouter 侧回合 ID（rnd_ 前缀）。同一局的所有回调都挂它。
          examples: ["rnd_01H8XGJWBW"]
        sequence:
          type: integer
          minimum: 1
          description: 回合内单调递增序号（1,2,3…不跳号）。可用于断言投递顺序。
        finished:
          type: boolean
          description: 收口标志。收口不变量：每个回合最终恰好一次 true。收口后仍可能到达迟到的 rollback（更正流，不携带 finished:true），照常处理。

    CommonEnvelope:
      type: object
      description: 所有资金回调（debit/credit/settle/rollback）的公共字段。
      required: [transaction_id, player_id, currency, occurred_at]
      properties:
        transaction_id:
          type: string
          description: 幂等键 + 业务主键（txn_ 前缀，全局唯一）。重复请求返回 200 + 当前余额 + duplicate:true；不要求持久化原响应。
          examples: ["txn_01H8XGJWBWK9"]
        player_id:
          type: string
          description: 运营商侧玩家 ID（sessions/create 时传入，原样带回）。
          examples: ["wang-123"]
        currency:
          type: string
          pattern: "^[A-Z0-9]{3,4}$"
          description: 币种。法币 = ISO 4217 三字母；加密货币 = 3–4 位字母数字。本次调用事件的交易币种（与会话/回合一致）；玩家可支持多币种，不在该玩家支持范围内 → declined currency_mismatch。
          examples: ["BRL", "USDT"]
        game_code:
          type: string
          description: |
            游戏标识 = `<provider>:<游戏段>`，**不透明键**（与方向 A games/list 的 game_code 同一把键），有游戏上下文时给。
            多产品线厂商（目前只有 `db`）的游戏段形如 `card-101`（产品线-厂商游戏码）；单产品线厂商的游戏段就是厂商游戏码原文。
            不要自行拆游戏段——产品维度看 `provider_product`。
          examples: ["pg:fortune-tiger", "db:card-101"]
        provider:
          type: string
          description: 厂商代号（来自方向 A 本面 providers/list 的 code，厂商族码如 pg / db / pp / saba），有游戏上下文时给。
          examples: ["pg"]
        provider_product:
          type: string
          description: 可选回显。厂商产品维度键（多产品线厂商时出现），值域同方向 A games/list 的 `provider_product` / providers/list 的 `products[].code`。零处理义务。
          examples: ["card"]
        round:
          $ref: "#/components/schemas/Round"
        session_id:
          type: string
          description: PlayRouter 侧会话 ID（ses_ 前缀）。排障坐标，零处理义务。
          examples: ["ses_01H8XGJWBW"]
        session_payload:
          type: string
          maxLength: 1024
          description: 运营商 sessions/create 时自定义的透传串，原样奉还。零处理义务。
          examples: ["wsess-8f2|ios|brand-B"]
        occurred_at:
          type: string
          format: date-time
          description: 事件发生时刻，RFC 3339 UTC 恒 3 位毫秒。
          examples: ["2026-07-03T12:00:00.000Z"]

    BalanceRequest:
      type: object
      required: [player_id, currency]
      properties:
        player_id: { type: string, examples: ["wang-123"] }
        currency: { type: string, pattern: "^[A-Z0-9]{3,4}$", examples: ["BRL"] }
        game_code: { type: string, description: "游戏键 `<provider>:<游戏段>`（不透明键），有游戏上下文时给。", examples: ["pg:fortune-tiger", "db:card-101"] }
        provider_product: { type: string, description: "可选回显。厂商产品维度键（多产品线厂商时出现），值域同方向 A games/list 的 provider_product。零处理义务。", examples: ["card"] }
        session_id: { type: string, examples: ["ses_01H8XGJWBW"] }
        session_payload: { type: string, examples: ["wsess-8f2|ios"] }

    DebitRequest:
      allOf:
        - $ref: "#/components/schemas/CommonEnvelope"
        - type: object
          required: [amount, source]
          properties:
            amount:
              $ref: "#/components/schemas/Money"
            source:
              type: string
              description: |
                资金语义标签（开放枚举，未知值不得拒绝）：
                bet=游戏下注 · bonus_buy=功能购买 · tip=真人桌小费 · entry_fee=锦标赛/活动报名费 · adjustment=事后更正（负向）。
              examples: ["bet", "bonus_buy", "tip", "entry_fee", "adjustment"]
            campaign_id:
              type: string
              description: 可选。资金源于运营商经方向 A 创建的活动时出现（cmp_ 前缀），回调到达即可归因。
              examples: ["cmp_01H8XGJWBW"]

    CreditRequest:
      allOf:
        - $ref: "#/components/schemas/CommonEnvelope"
        - type: object
          required: [amount, source]
          properties:
            amount:
              $ref: "#/components/schemas/Money"
            source:
              type: string
              description: |
                资金语义标签（开放枚举，未知值不得拒绝）：
                game_win=游戏派彩 · freespin_payout=免费旋转结算 · freechip_payout=免费筹码结算 ·
                bonus_buy_payout=功能购买派彩 · promo_win=活动奖金 · jackpot=彩池派彩 ·
                adjustment=事后更正（正向） · round_finish=零额回合收口（amount 为零，如 "0.00"）。
              examples: ["game_win", "freespin_payout", "round_finish"]
            related_transaction_id:
              type: string
              description: 可选。指向对应 debit 的 txn_；出现则保证已成功送达过你（承诺③），永不悬空。
              examples: ["txn_01H8XGJWBWK9"]
            campaign_id:
              type: string
              description: 可选。资金源于运营商活动时出现（cmp_）。
              examples: ["cmp_01H8XGJWBW"]

    SettleRequest:
      allOf:
        - $ref: "#/components/schemas/CommonEnvelope"
        - type: object
          required: [bet, win, source]
          properties:
            bet:
              $ref: "#/components/schemas/Money"
            win:
              $ref: "#/components/schemas/Money"
            source:
              type: string
              description: 合笔结算流量恒为 "game"。
              examples: ["game"]

    RollbackRequest:
      allOf:
        - $ref: "#/components/schemas/CommonEnvelope"
        - type: object
          required: [original_transaction_id]
          properties:
            original_transaction_id:
              type: string
              description: 要撤销的原交易（保证是你成功处理过的，承诺①）。原为扣则退、原为入则收回。
              examples: ["txn_01H8XGJWBWK9"]

    OkResponse:
      type: object
      required: [status, balance]
      properties:
        status:
          type: string
          const: ok
        balance:
          $ref: "#/components/schemas/BalanceValue"
        operator_transaction_id:
          type: string
          description: 可选。运营商侧账目 ID 回执，双方对账互查用。
          examples: ["op-ledger-991"]
        duplicate:
          type: boolean
          description: 幂等重复时为 true（返回当前余额，不重复记账）。
      examples:
        - { status: "ok", balance: "114.50", operator_transaction_id: "op-991" }
        - { status: "ok", balance: "114.50", duplicate: true }

    DeclinedResponse:
      type: object
      required: [status, reason]
      description: |
        业务拒绝（仅 balance / debit / settle 允许）。终态，PlayRouter 绝不重试。
        balance 必填规则：钱包可定位的拒绝（insufficient_funds / player_blocked / bet_limit_exceeded）必须附 balance；
        钱包不可定位（player_not_found / currency_mismatch）允许省略。
      properties:
        status:
          type: string
          const: declined
        reason:
          type: string
          description: 开放枚举（语义字符串）。
          examples: ["insufficient_funds", "player_not_found", "player_blocked", "bet_limit_exceeded", "currency_mismatch"]
        balance:
          $ref: "#/components/schemas/BalanceValue"
      examples:
        - { status: "declined", reason: "insufficient_funds", balance: "3.20" }

    ErrorResponse:
      type: object
      required: [status, reason]
      description: 非业务处理失败。目前仅 invalid_signature——PlayRouter 视为配置故障（告警 + 冻结投递），不重试轰炸。
      properties:
        status:
          type: string
          const: error
        reason:
          type: string
          examples: ["invalid_signature"]
      examples:
        - { status: "error", reason: "invalid_signature" }
