openapi: 3.1.0
info:
  title: OrbitPay 商户接口
  version: 1.0.0
  summary: 一套请求头签名的 JSON 接口，用于发起收款与付款，并查询其结果。
  description: |
    # 这套接口是什么

    六个端点，两个方向。`/orbit` 之下的一切都是 JSON 进、JSON 出，
    每一次调用都必须带签名。

    | 方向 | 你在做什么 |
    |---|---|
    | 收款（collect） | 让付款人把钱付进你的账户 |
    | 付款（disburse） | 把钱从你的账户打给收款人 |

    三条贯穿全文的规则，先记住它们，后面的字段表就都好懂了：

    1. **没有免签通道。** 每个请求都要签，包括两个只读查询。
    2. **每个请求只能用一次。** 时间戳 + 一次性 nonce，重放会被拒。
    3. **认证失败不告诉你为什么。** 密钥不存在和密钥不对，返回的是同一个
       `INVALID_MERCHANT`。这是刻意的：接口不会变成一个探测密钥是否存在的工具。

    > **字段表怎么读：** 标了 **必填** 的漏掉就是 `INVALID_PARAMS`。
    > 没标的都可以不传。带条件的会写清楚条件是什么。

    ---

    # 0 · 字段名是本接口专有的

    **这一节请先读完再写代码。**

    本接口的字段名是 OrbitPay 自己的一套，**以本文档为准**。
    照搬其他来源的字段名会被拒绝。

    传错了不会被忽略，而是会拿到 `INVALID_PARAMS`。返回文案刻意保持简短，
    正确的名字到下面的表里查：

    ```json
    {
      "ok": false,
      "result_code": "INVALID_PARAMS",
      "result_text": "unexpected parameter",
      "payload": null,
      "trace_ref": "req_9f3c1a7e5b204d88"
    }
    ```

    这是好事 —— 拼错名字会当场炸掉，而不是让一个字段静默丢失、直到对账时才发现。

    最容易搞混的几个，记一下：

    | 你的订单号 | `merchant_ref` |
    |---|---|
    | 我们的订单号 | `gateway_ref` |
    | 下单金额 | `gross_amount`（响应）/ `charge_amount`、`payout_amount`（请求） |
    | 手续费 | `charge` |
    | 实收 / 实扣 | `net_amount` |
    | 订单状态 | `state` |
    | 签名摘要 | `checksum` |

    **响应信封也是自己的一套**：`ok` / `result_code` / `result_text` /
    `payload` / `trace_ref`，见第 3 节。

    ---

    # 1 · 拿到你的密钥对

    两个值，一起签发，用途完全不同：

    | | 长什么样 | 干什么用 |
    |---|---|---|
    | **API key** | `pk_` 加 25 个字符，一共 28 位 | 放在 `X-Api-Key` 头里明文发送。它只是个身份标识，**不是密码**。 |
    | **API secret** | 48 个字符 | 签名用的 HMAC 密钥。**永远不要放进请求里**，也不要出现在浏览器或 App 的代码中。 |

    **去哪拿：** 商户后台 → **Profile** → 显示 API 凭证。会要你输入验证器 App 上
    当前的 6 位动态码。

    > **secret 只显示这一次。** 当场复制进你服务端的配置。刷新页面不会再给你看。
    >
    > **丢了怎么办：** 找不回来。联系客服重置，会签发**一对全新的 key 和 secret**，
    > 旧的在重置完成的瞬间失效 —— 所以请自己安排一个切换窗口，别在流量高峰做。

    账户还需要单独开通 API 权限。没开通的话，每个请求都会拿到
    `API_V2_NOT_ENABLED`，找客服开。

    如果你配了 IP 白名单，白名单之外的来源会直接吃 `IP_RESTRICTION`。

    ---

    # 2 · 给一个请求签名

    四个头，一个都不能少：

    | 请求头 | 内容 |
    |---|---|
    | `X-Api-Key` | 你的 API key，原样。 |
    | `X-Timestamp` | Unix 秒。与网关时间相差超过 **300 秒**即被拒。 |
    | `X-Nonce` | 本次请求独有的随机串。**600 秒**内不可重复使用。 |
    | `X-Signature` | 下面算出来的十六进制串。 |

    ### 待签名串

    五行，用 `\n` 连接，**没有结尾换行**：

    ```
    v2:{METHOD}
    {PATH}
    {TIMESTAMP}
    {NONCE}
    {SHA256_HEX_BODY}
    ```

    逐行说明：

    - `{METHOD}` —— 大写，`POST` 或 `GET`。
    - `{PATH}` —— 请求路径，以 `/` 开头，**不含**查询串和域名。
      本接口就是 `/orbit/...`，例如 `/orbit/collect/create`。
    - `{TIMESTAMP}` —— 与 `X-Timestamp` 头**完全一致**的那个值。
    - `{NONCE}` —— 与 `X-Nonce` 头**完全一致**的那个值。
    - `{SHA256_HEX_BODY}` —— 原始请求体的 SHA-256，小写十六进制。
      **GET 没有请求体**，这一行是空串的哈希，即
      `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`。

    等价的拼接写法，也就是网关实际拼出来的那一串：

    ```
    "v2:" + METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + sha256hex(body)
    ```

    然后：

    ```
    X-Signature = HMAC-SHA256(待签名串, API secret)   // 小写十六进制
    ```

    与其对着文档核对，不如直接跑一遍：**[签名自测工具](tester.html)**
    接受你的 key、secret、路径和请求体，把请求体哈希、待签名串、签名三个值
    分别显示出来，一眼就能看出是哪一步对不上。

    一个 POST 的完整例子（时间戳与 nonce 是示意值）：

    ```
    v2:POST
    /orbit/collect/create
    1786012345
    3f9a1c604b5a7182
    46baa9e32a9fe974091eee3a1e3b7cb4d4e6450fbba66b2e3390991d0293660f
    ```

    ### 最容易踩的四个坑

    1. **先序列化，再哈希。** `{SHA256_HEX_BODY}` 必须是你**真正发出去的那串字节**
       的哈希。如果你的 HTTP 库重新序列化了一遍 JSON（改了键顺序、空格、
       Unicode 转义），哈希就对不上了。请先生成字符串，对它哈希，再把同一个字符串
       发出去。
    2. **`{PATH}` 不带查询串。** 有查询参数也不进待签名串。
    3. **时间戳是秒，不是毫秒。** 传毫秒会稳定地拿到 `SIGNATURE_EXPIRED`。
    4. **nonce 要真随机。** 用计数器的话，重启后会撞上还没过期的旧值，
       拿到 `INVALID_SIGNATURE`。UUIDv4 就够了。

    ### 签名相关的三种失败

    | 返回码 | 通常意味着 |
    |---|---|
    | `INVALID_MERCHANT` | key 不存在，或者 key 与 secret 不配对。两种情况返回一样。 |
    | `SIGNATURE_EXPIRED` | 时间戳超出 ±300 秒。先校准你的服务器时钟。 |
    | `INVALID_SIGNATURE` | 待签名串跟网关算出来的不一致，或者 nonce 在 600 秒内重复了。 |

    ---

    # 3 · 响应长什么样

    除回调外，每个响应都是同一个信封：

    ```json
    {
      "ok": true,
      "result_code": "SUCCESS",
      "result_text": "OK",
      "payload": { },
      "trace_ref": "req_9f3c1a7e5b204d88"
    }
    ```

    | 字段 | 说明 |
    |---|---|
    | `ok` | 布尔。`true` 表示业务成功。 |
    | `result_code` | 机器可读的结果码，取值见错误码表。成功恒为 `SUCCESS`。 |
    | `result_text` | 给人看的说明。**不要拿它做分支判断**，文案会变。 |
    | `payload` | 本次调用的载荷。失败时可能为 `null`。 |
    | `trace_ref` | 本次请求的标识。**报障时请带上它**，这是我们定位问题最快的方式。 |

    **请依据 `result_code` 判断成败，不要依据 HTTP 状态码。** 业务层面的拒绝
    （余额不足、渠道未开通）可能伴随 200。

    ---

    # 4 · 回调

    订单进入终态时，网关会 POST 到你的 `notify_endpoint`。

    回调**不套上面的信封** —— 请求体就是裸的 JSON 对象，字段名与查询接口的
    `payload` 一致。

    回调也带签名，但待签名串更短（没有方法和路径）：

    ```
    v2-callback:{TIMESTAMP}
    {NONCE}
    {SHA256_HEX_BODY}
    ```

    拼接写法：

    ```
    "v2-callback:" + TIMESTAMP + "\n" + NONCE + "\n" + sha256hex(body)
    ```

    头是三个：`X-Timestamp`、`X-Nonce`、`X-Signature`。同样的 ±300 秒和
    600 秒 nonce 规则。

    **你必须验签。** 否则任何人都能往你的回调地址伪造一笔「已完成」。

    > **关于 `checksum`：** 它是网关按**本接口的字段名**算出来的摘要 ——
    > 也就是你实际收到的那些键名，不是任何内部名称。所以你可以直接按收到的
    > 报文校验，不需要先做一次名称转换。

    ### 你的接收端要做到

    - **验签之后再动账。**
    - **幂等。** 同一笔订单的回调可能到达多次 —— 重试、管理员手动重推都会。
      按 `merchant_ref` 去重。
    - **回 2xx。** 非 2xx 会被当作失败并重试。
    - **快点回。** 先落库再处理，别在回调里做慢活。
    - **别信金额来自你自己的记录。** 以回调里的 `net_amount` 为准，
      那才是实际结算的数。

    ---

    # 5 · 错误码

    这些是 `result_code` 的取值。

    | Code | 含义 |
    |---|---|
    | `SUCCESS` | 成功。 |
    | `FAIL` | 通用失败，看 `result_text`。 |
    | `INVALID_PARAMS` | 缺必填字段、字段格式不对，或用了本接口不认识的字段名。 |
    | `INVALID_MERCHANT` | key 不存在或与 secret 不配对。 |
    | `INVALID_SIGNATURE` | 签名不匹配，或 nonce 重复。 |
    | `SIGNATURE_EXPIRED` | 时间戳超出容差。 |
    | `API_V2_NOT_ENABLED` | 账户未开通本接口。 |
    | `IP_RESTRICTION` | 来源 IP 不在白名单内。 |
    | `MERCHANT_BLOCKED` | 账户被停用。 |
    | `MERCHANT_INSUFFICIENT_BALANCE` | 余额不足以完成这笔付款。 |
    | `DUPLICATE_TRANSACTION` | 这个 `merchant_ref` 已经用过了。 |
    | `TRANSACTION_NOT_FOUND` | 查不到这笔订单。 |
    | `PAYMENT_METHOD_NOT_SUBSCRIBE` | 该渠道未为你的账户开通。 |
    | `NO_SERVICE_PROVIDED` | 当前没有可用的服务方。 |
    | `SERVICE_NOT_AVAILABLE` | 服务暂时不可用。 |
    | `SERVICE_UNDER_MAINTENANCE` | 服务维护中。 |
    | `PAYMENT_GATEWAY_MAINTENENCE` | 网关维护中。 |
    | `TOO_MANY_REQUEST` | 触发限流，退避后重试。 |
    | `PATH_NOT_FOUND` | 路径不存在。 |

    ---

    # 7 · 自测向量

    固定输入、固定输出。用它们校验你的签名实现，逐字符比对；
    [签名自测工具](tester.html) 用的就是这三条。

    ```
    X-Api-Key    pk_orbitsample0000000000live
    API secret   orbitsecretorbitsecretorbitsecretorbitsecretlive
    X-Timestamp  1786012345
    X-Nonce      3f9a1c604b5a7182
    ```

    **① POST `/orbit/collect/create`**

    ```
    {"method":"bank","charge_amount":"880.00","merchant_ref":"ob-sample-000001","notify_endpoint":"https://merchant.example.net/hooks/orbit/collect","return_endpoint":"https://merchant.example.net/hooks/orbit/return"}
    ```

    SHA256(body) = `46baa9e32a9fe974091eee3a1e3b7cb4d4e6450fbba66b2e3390991d0293660f`
    → X-Signature = `0d3c7381afb58f28045c040a300e9d913e190c1ee6ca9646004840fe240c888e`

    **② GET `/orbit/account/available`**（空体）

    SHA256("") = `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`
    → X-Signature = `246e0352cf68ba214327c3d01a460123a27edae0ed8eac6883ec33d1090a7f7b`

    **③ 收款回调**（订单 `DP1786012345SAMPLE02`）

    ```
    {"gateway_ref":"DP1786012345SAMPLE02","merchant_ref":"ob-sample-000001","remitter_name":"-","state":"COMPLETE","gross_amount":"880.00","charge":"13.20","net_amount":"866.80"}
    ```

    SHA256(body) = `5e94dbca9fdea3cf1210c9e52378a15841a4d23ca627dcc8793b80c37f8273bd`
    → X-Signature = `9c72d330a41b4cf6dc049b1a0ffe5b56c667c127b02c158812bd98998bb766f6`

    ---

    # 6 · 接入顺序建议

    1. 调 `GET /orbit/account/available`。它没有请求体，是验证签名最省事的一步 ——
       签名对了才谈得上别的。
    2. 调 `GET /orbit/reference/banks` 拿到银行代码表，落到你自己的库里。
    3. 用小额跑通一笔收款，验证回调能收到并且验签通过。
    4. 再接付款。

    第 1 步跑不通就不要往下走 —— 后面每一个接口用的是同一套签名。
  contact:
    name: 商户技术支持
  x-profile-digest: sha256:f4fa80d283eda3455f639505c60701338a2a0166d9eba3ed9f39424ce05488c4

servers:
  - url: https://api.orbitpayhk.com
    description: 生产网关

tags:
  - name: 收款通道
    description: 发起收款订单，并查询它的结算结果。
  - name: 付款通道
    description: 发起付款订单，并查询它的结算结果。
  - name: 账户与参考数据
    description: 余额与银行代码表，两个只读接口。

security:
  - ApiKey: []
    Timestamp: []
    Nonce: []
    Signature: []

paths:
  /orbit/collect/create:
    post:
      tags: [收款通道]
      operationId: createCollectOrder
      summary: 发起一笔收款
      description: |
        建单，拿到付款指令，把它交给付款人。

        响应里的 `instructions` 是一个**不定形状**的对象 —— 里面装什么取决于
        渠道给回来的是什么：银行转账会有账号和户名，转数快可能是一个 FPS ID 或
        一张二维码。**请把它当作透传内容展示，不要对它的结构做假设**，
        新增字段不会另行通知。

        `merchant_ref` 是你这边的订单号，全局唯一。重复提交同一个值会拿到
        `DUPLICATE_TRANSACTION` —— 这也正是网络超时后安全重试的方式：
        重发同一个 `merchant_ref`，要么建单成功，要么明确告诉你已经建过了。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CollectCreateRequest'
            examples:
              bank:
                summary: 银行转账
                value:
                  merchant_ref: OB-2608150931-44127
                  charge_amount: '880.00'
                  method: bank
                  remitter_name: 周敏
                  notify_endpoint: https://merchant.example.net/hooks/orbit/collect
              fps:
                summary: 转数快
                value:
                  merchant_ref: OB-2608150932-44128
                  charge_amount: '1500.00'
                  method: fps
                  customer_id: shop-77219
                  notify_endpoint: https://merchant.example.net/hooks/orbit/collect
                  return_endpoint: https://merchant.example.net/thanks
      responses:
        '200':
          description: 收款单已受理，付款指令随之返回。
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      payload:
                        $ref: '#/components/schemas/CollectCreated'
              examples:
                sample:
                  value:
                    ok: true
                    result_code: SUCCESS
                    result_text: OK
                    payload:
                      merchant_ref: OB-2608150931-44127
                      gateway_ref: '2608150931441270'
                      gross_amount: '880.00'
                      charge: '13.20'
                      net_amount: '866.80'
                      remitter_name: 周敏
                      instructions:
                        bank_name: 花旗銀行（香港）
                        account_number: '024-887410-883'
                        account_name: ORBIT COMMERCE LTD
                    trace_ref: req_9f3c1a7e5b204d88
        '403': { $ref: '#/components/responses/AuthError' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /orbit/collect/status/{merchant_ref}:
    get:
      tags: [收款通道]
      operationId: showCollectOrder
      summary: 查询一笔收款
      description: |
        按你自己的 `merchant_ref` 查这笔收款现在是什么状态。

        **这个接口是给对账兜底的，不是用来轮询的。** 正常路径是等回调。
        只有在回调迟迟没来、或者你在做日终对账时才来查这里。高频轮询会撞上
        `TOO_MANY_REQUEST`。

        响应里的 `checksum` 与回调里那个字段同源，可以用来核对你收到的回调
        确实描述的是同一笔订单。
      parameters:
        - $ref: '#/components/parameters/MerchantRef'
      responses:
        '200':
          description: 找到订单。
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      payload:
                        $ref: '#/components/schemas/CollectRecord'
              examples:
                sample:
                  value:
                    ok: true
                    result_code: SUCCESS
                    result_text: OK
                    payload:
                      merchant_ref: OB-2608150931-44127
                      gateway_ref: '2608150931441270'
                      gross_amount: '880.00'
                      charge: '13.20'
                      net_amount: '866.80'
                      remitter_name: 周敏
                      state: COMPLETE
                      checksum: 6b1f0d8a4e27c539ab7402ee15c8d9f3
                    trace_ref: req_1c84be2079fd4a56
        '403': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /orbit/disburse/create:
    post:
      tags: [付款通道]
      operationId: createDisburseOrder
      summary: 发起一笔付款
      description: |
        把钱打到一个银行账户。

        **收款方信息在下单这一刻定型。** 建单之后改不了 —— 打错了只能等它走完
        再另行处理。请在提交前校验账号。

        `beneficiary_bank` 必须取自 `GET /orbit/reference/banks` 返回的
        `bank_key`，否则是 `INVALID_WITHDRAW_BANK`。别把银行名硬编码进代码，
        代码表会变。

        余额不足会立刻拿到 `MERCHANT_INSUFFICIENT_BALANCE`，订单不会创建。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DisburseCreateRequest'
            examples:
              bank:
                summary: 银行通道（method = bank）
                value:
                  merchant_ref: OB-2608151104-44903
                  payout_amount: '5200.00'
                  method: bank
                  beneficiary_bank: CITIHKHX
                  beneficiary_account: '395-004821-770'
                  beneficiary_name: 李國強
                  notify_endpoint: https://merchant.example.net/hooks/orbit/disburse
              fps:
                summary: 转数快通道（method = fps）
                value:
                  merchant_ref: OB-2608151104-44904
                  payout_amount: '5200.00'
                  method: fps
                  beneficiary_mobile: '85261234567'
                  holder_name: 李國強
                  notify_endpoint: https://merchant.example.net/hooks/orbit/disburse
      responses:
        '200':
          description: 订单已创建，等待结算。
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      payload:
                        $ref: '#/components/schemas/DisburseCreated'
              examples:
                sample:
                  value:
                    ok: true
                    result_code: SUCCESS
                    result_text: OK
                    payload:
                      merchant_ref: OB-2608151104-44903
                      gateway_ref: '2608151104449030'
                      gross_amount: '5200.00'
                      charge: '26.00'
                      net_amount: '5174.00'
                    trace_ref: req_ad25f6910b384ce7
        '403': { $ref: '#/components/responses/AuthError' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /orbit/disburse/status/{merchant_ref}:
    get:
      tags: [付款通道]
      operationId: showDisburseOrder
      summary: 查询一笔付款
      description: |
        按你自己的 `merchant_ref` 查这笔付款的状态。与收款查询同理 ——
        对账用，别拿来轮询。
      parameters:
        - $ref: '#/components/parameters/MerchantRef'
      responses:
        '200':
          description: 找到订单。
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      payload:
                        $ref: '#/components/schemas/DisburseRecord'
              examples:
                sample:
                  value:
                    ok: true
                    result_code: SUCCESS
                    result_text: OK
                    payload:
                      merchant_ref: OB-2608151104-44903
                      gateway_ref: '2608151104449030'
                      gross_amount: '5200.00'
                      charge: '26.00'
                      net_amount: '5174.00'
                      state: PENDING
                      checksum: c07a935e18b4d62f0ae3719cb2854dd6
                    trace_ref: req_88b1043fe75a29cc
        '403': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /orbit/reference/banks:
    get:
      tags: [账户与参考数据]
      operationId: listBanks
      summary: 可用银行代码表
      description: |
        返回你的账户当前能打款到的银行。

        **`bank_key` 是唯一有意义的字段** —— 付款下单时 `beneficiary_bank`
        要传的就是它。`bank_label` 只用于展示，`bank_ref` 是网关内部标识。

        这张表会变（新增银行、下线银行）。请定期同步，不要一次抓完就当常量。
      responses:
        '200':
          description: 银行列表。
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      payload:
                        type: array
                        items: { $ref: '#/components/schemas/Bank' }
              examples:
                sample:
                  value:
                    ok: true
                    result_code: SUCCESS
                    result_text: OK
                    payload:
                      - bank_ref: 12
                        bank_label: 花旗銀行（香港）
                        bank_key: CITIHKHX
                      - bank_ref: 27
                        bank_label: 東亞銀行
                        bank_key: BEASHKHH
                    trace_ref: req_6300cd9a4b17e8f2
        '403': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /orbit/account/available:
    get:
      tags: [账户与参考数据]
      operationId: showAvailableBalance
      summary: 账户可用余额
      description: |
        返回当前可用于付款的余额。

        **接入时先调它。** 没有请求体、没有参数，签名出错就只可能出在签名本身，
        排查范围最小。

        余额是查询那一刻的快照。不要用它做付款前的准入判断 ——
        并发的订单会让它在你读到和你下单之间发生变化。真正的判定在下单时，
        失败会返回 `MERCHANT_INSUFFICIENT_BALANCE`。
      responses:
        '200':
          description: 此刻的可用余额。
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      payload:
                        type: object
                        properties:
                          available:
                            type: string
                            description: 可用余额，两位小数的十进制字符串。
              examples:
                sample:
                  value:
                    ok: true
                    result_code: SUCCESS
                    result_text: OK
                    payload:
                      available: '318740.55'
                    trace_ref: req_0a9e63b7cc4f5218
        '403': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

webhooks:
  collectCallback:
    post:
      tags: [收款通道]
      operationId: collectCallback
      security: []
      summary: 收款终态推送
      description: |
        收款订单进入终态时（以及管理员手动重推时）POST 到你的 `notify_endpoint`。

        请求体是**裸 JSON**，不套信封。用
        `v2-callback:{TIMESTAMP}\n{NONCE}\n{SHA256_HEX_BODY}` 验签，
        头是 `X-Timestamp`、`X-Nonce`、`X-Signature`。

        以 `net_amount` 为准入账 —— 那是扣掉 `charge` 之后实际结算给你的数。
      parameters:
        - $ref: '#/components/parameters/CallbackTimestamp'
        - $ref: '#/components/parameters/CallbackNonce'
        - $ref: '#/components/parameters/CallbackSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CollectRecord' }
            examples:
              sample:
                value:
                  merchant_ref: OB-2608150931-44127
                  gateway_ref: '2608150931441270'
                  gross_amount: '880.00'
                  charge: '13.20'
                  net_amount: '866.80'
                  remitter_name: 周敏
                  state: COMPLETE
                  checksum: 6b1f0d8a4e27c539ab7402ee15c8d9f3
      responses:
        '200':
          description: |
            回 2xx 表示已收到。非 2xx 会被重试。

  disburseCallback:
    post:
      tags: [付款通道]
      operationId: disburseCallback
      security: []
      summary: 付款结果回调
      description: |
        付款订单进入终态时 POST 到你的 `notify_endpoint`。格式与验签方式跟
        收款回调完全相同，只是少了 `remitter_name`（付款没有付款人这个概念）。
      parameters:
        - $ref: '#/components/parameters/CallbackTimestamp'
        - $ref: '#/components/parameters/CallbackNonce'
        - $ref: '#/components/parameters/CallbackSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DisburseRecord' }
            examples:
              sample:
                value:
                  merchant_ref: OB-2608151104-44903
                  gateway_ref: '2608151104449030'
                  gross_amount: '5200.00'
                  charge: '26.00'
                  net_amount: '5174.00'
                  state: COMPLETE
                  checksum: c07a935e18b4d62f0ae3719cb2854dd6
      responses:
        '200':
          description: 回 2xx 表示已收到。

components:
  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: X-Api-Key
      description: 你的 API key，明文。它是标识，不是密码。
    Timestamp:
      type: apiKey
      in: header
      name: X-Timestamp
      description: Unix 秒。与网关时间相差超过 300 秒即拒。
    Nonce:
      type: apiKey
      in: header
      name: X-Nonce
      description: 单次使用的随机串，600 秒内不可重复。
    Signature:
      type: apiKey
      in: header
      name: X-Signature
      description: |
        `HMAC-SHA256(v2:{METHOD}\n{PATH}\n{TIMESTAMP}\n{NONCE}\n{SHA256_HEX_BODY}, secret)`，
        小写十六进制。

  parameters:
    MerchantRef:
      name: merchant_ref
      in: path
      required: true
      description: 你在创建订单时提交的那个订单号。
      schema:
        type: string
      example: OB-2608150931-44127
    CallbackTimestamp:
      name: X-Timestamp
      in: header
      required: true
      description: 回调的 Unix 秒时间戳，参与待签名串。
      schema: { type: string }
    CallbackNonce:
      name: X-Nonce
      in: header
      required: true
      description: 本次回调的一次性随机串。
      schema: { type: string }
    CallbackSignature:
      name: X-Signature
      in: header
      required: true
      description: |
        `HMAC-SHA256(v2-callback:{TIMESTAMP}\n{NONCE}\n{SHA256_HEX_BODY}, secret)`。
        **动账之前必须验它。**
      schema: { type: string }

  responses:
    AuthError:
      description: |
        认证被拒。`INVALID_MERCHANT`（key 不存在或不配对）、
        `SIGNATURE_EXPIRED`（时间戳超差）或 `INVALID_SIGNATURE`
        （签名不符或 nonce 重复）。
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Envelope' }
          examples:
            sample:
              value:
                ok: false
                result_code: INVALID_SIGNATURE
                result_text: Signature verification failed
                payload: null
                trace_ref: req_7b41d0e2953f6a08
    ValidationError:
      description: |
        请求体没通过校验。用了本接口不认识的字段名也归到这里，
        `result_text` 会直接告诉你正确的名字。
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Envelope' }
          examples:
            sample:
              value:
                ok: false
                result_code: INVALID_PARAMS
                result_text: "unexpected parameter"
                payload: null
                trace_ref: req_2d6f88ac1e70b345
    RateLimited:
      description: 请求过于频繁。退避后重试。
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Envelope' }
          examples:
            sample:
              value:
                ok: false
                result_code: TOO_MANY_REQUEST
                result_text: Too many requests
                payload: null
                trace_ref: req_e51907b3da24c6f0
    ServerError:
      description: |
        网关侧异常。**不要把它当作订单失败** —— 订单可能已经建了。
        用同一个 `merchant_ref` 重试，或调查询接口确认。
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Envelope' }
          examples:
            sample:
              value:
                ok: false
                result_code: FAIL
                result_text: Unexpected error
                payload: null
                trace_ref: req_3ca0f7128be495dd

  schemas:
    Envelope:
      type: object
      description: 除回调外，所有响应共用的外层结构。
      properties:
        ok:
          type: boolean
          description: 业务是否成功。
        result_code:
          $ref: '#/components/schemas/ErrorCode'
        result_text:
          type: string
          description: 给人看的说明。文案会变，别用它做分支。
        payload:
          description: 载荷。失败时可能为 null。
        trace_ref:
          type: string
          description: 本次请求标识。报障请带上。
      required: [ok, result_code, result_text, trace_ref]

    ErrorCode:
      type: string
      description: 机器可读的结果码。完整含义见概述第 5 节。
      enum:
        - SUCCESS
        - FAIL
        - INVALID_PARAMS
        - INVALID_MERCHANT
        - INVALID_SIGNATURE
        - SIGNATURE_EXPIRED
        - API_V2_NOT_ENABLED
        - IP_RESTRICTION
        - MERCHANT_BLOCKED
        - MERCHANT_INSUFFICIENT_BALANCE
        - DUPLICATE_TRANSACTION
        - TRANSACTION_NOT_FOUND
        - PAYMENT_METHOD_NOT_SUBSCRIBE
        - NO_SERVICE_PROVIDED
        - SERVICE_NOT_AVAILABLE
        - SERVICE_UNDER_MAINTENANCE
        - PAYMENT_GATEWAY_MAINTENENCE
        - TOO_MANY_REQUEST
        - PATH_NOT_FOUND

    CollectMethod:
      type: string
      description: |
        收款渠道。两条银行通道：`bank`（银行转账）与 `fps`（转数快，
        Faster Payment System）。其他取值会被拒绝并返回 `INVALID_PARAMS`。
      enum: [bank, fps]

    DisburseMethod:
      type: string
      description: 付款渠道。
      enum: [bank, fps]

    OrderState:
      type: string
      description: |
        订单状态，字段名是 `state`。

        | 取值 | 含义 |
        |---|---|
        | `PENDING` | 还没结算，仍在处理。 |
        | `COMPLETE` | 已结算成功。**只有这个状态代表资金真的动了。** |
        | `REJECT` | 已拒绝或失败。 |
        | `OVERTIME` | 仅收款 —— 付款人没在有效期内付款。 |

        `COMPLETE` 和 `REJECT` 是终态。**其余一切取值（包括这里没列出的）
        都按「仍在处理」对待，继续等回调，绝不要当成失败。**
      enum: [PENDING, COMPLETE, REJECT, OVERTIME]

    CollectCreateRequest:
      type: object
      required: [merchant_ref, charge_amount, method]
      properties:
        merchant_ref:
          type: string
          description: '**必填。** 你这边的订单号，全局唯一。重复会返回 `DUPLICATE_TRANSACTION`。'
        charge_amount:
          type: string
          description: '**必填。** 两位小数的十进制字符串。低于渠道下限返回 `MINIMUM_AMOUNT_REQUIRED`，高于上限返回 `MAX_AMOUNT_TIER`。'
        method:
          $ref: '#/components/schemas/CollectMethod'
        notify_endpoint:
          type: string
          description: 终态推送要送到哪个地址。留空就没有推送，只能靠查询接口。
        return_endpoint:
          type: string
          description: 付款人完成付款后跳回的地址。
        remitter_name:
          type: string
          description: 付款人姓名。部分渠道用它做入账匹配。
        bank_code:
          type: string
          description: |
            指定收款银行，取值是银行表的 `bank_key`。

            注意这里叫 `bank_code` 而付款那边叫 `beneficiary_bank` ——
            两者含义不同：这个是「钱进哪家银行」，那个是「钱出到哪家银行」。
        customer_id:
          type: string
          description: 你这边的客户标识，原样回传，便于你对账。
        rand_strategy:
          type: string
          description: 金额随机化策略，用于区分同额并发订单。按客服给你的取值填。
        reject_min:
          type: integer
          description: 超过这个分钟数仍未付款则自动拒单。

    CollectCreated:
      type: object
      properties:
        merchant_ref: { type: string, description: 你提交的订单号，原样回传。 }
        gateway_ref: { type: string, description: 网关侧订单号。 }
        gross_amount: { type: string, description: 下单金额。 }
        charge: { type: string, description: 手续费。 }
        net_amount: { type: string, description: 实际结算给你的金额。 }
        remitter_name: { type: string, description: 付款人姓名。 }
        instructions:
          type: object
          additionalProperties: true
          description: |
            **形状不定。** 由渠道决定：银行转账通常是账号与户名，
            转数快可能是 FPS ID 或二维码。透传展示即可，别对结构做假设。

    CollectRecord:
      type: object
      description: 收款订单的完整形态，查询接口和回调用的是同一个。
      properties:
        merchant_ref: { type: string }
        gateway_ref: { type: string }
        gross_amount: { type: string }
        charge: { type: string }
        net_amount: { type: string, description: '实际结算金额。**以它入账。**' }
        remitter_name: { type: string }
        state: { $ref: '#/components/schemas/OrderState' }
        checksum:
          type: string
          description: |
            网关按**本接口的字段名**算出的订单摘要，可用于核对回调与查询结果
            是否描述同一笔。

    DisburseCreateRequest:
      type: object
      required: [merchant_ref, payout_amount, method]
      properties:
        merchant_ref:
          type: string
          description: '**必填。** 你这边的订单号，全局唯一。'
        payout_amount:
          type: string
          description: '**必填。** 两位小数的十进制字符串。'
        method:
          $ref: '#/components/schemas/DisburseMethod'
        beneficiary_bank:
          type: string
          description: '银行类渠道必填，FPS 不传。收款银行，取自银行表的 `bank_key`。不在表内返回 `INVALID_WITHDRAW_BANK`。'
        beneficiary_account:
          type: string
          description: '银行类渠道必填，FPS 不传。收款账号。**建单后不可修改。**'
        beneficiary_name:
          type: string
          description: '`method: bank` 时必填。收款人户名，需与银行记录一致。`method: fps` 不用这个字段，请用 `holder_name`。'
        beneficiary_mobile:
          type: string
          description: '`method: fps` 时必填。收款人的 FPS 手机号或标识。'
        holder_name:
          type: string
          description: '`method: fps` 时必填 —— 转数快用来和 `beneficiary_mobile` 一起核对收款人的姓名。`method: bank` 不用这个字段，请用 `beneficiary_name`。'
        bank_name:
          type: string
          description: 银行名称，展示用。判定以 `beneficiary_bank` 为准。
        bank_id:
          type: integer
          description: 银行的网关内部标识，即银行表的 `bank_ref`。
        notify_endpoint:
          type: string
          description: 终态推送要送到哪个地址。
        return_endpoint:
          type: string
          description: 完成后跳转地址。

    DisburseCreated:
      type: object
      properties:
        merchant_ref: { type: string }
        gateway_ref: { type: string }
        gross_amount: { type: string }
        charge: { type: string }
        net_amount: { type: string, description: 实际从账户扣除的金额。 }

    DisburseRecord:
      type: object
      description: 付款订单的完整形态，查询接口和回调用的是同一个。
      properties:
        merchant_ref: { type: string }
        gateway_ref: { type: string }
        gross_amount: { type: string }
        charge: { type: string }
        net_amount: { type: string }
        state: { $ref: '#/components/schemas/OrderState' }
        checksum: { type: string }

    Bank:
      type: object
      properties:
        bank_ref:
          type: integer
          description: 网关内部标识。
        bank_label:
          type: string
          description: 银行名称，展示用。
        bank_key:
          type: string
          description: '**付款下单时 `beneficiary_bank` 要传的就是它。**'
