# 支付宝付款接口

UniPay 执行商户的打款指令。用户余额、冻结、提现资格和审核由业务系统负责；资金从商户自己的支付宝账户直接转入收款人的支付宝账户。

## 配置

1. 为出款应用开通支付宝对应转账产品（`TRANS_ACCOUNT_NO_PWD` / `DIRECT_TRANSFER`），并在支付宝配置真实业务场景。当面付收款权限不能替代转账权限。
2. 在商户后台「付款记录」生成独立付款 API 密钥，并保存到业务系统的服务端密钥存储。密钥只显示一次，与易支付收款 `key` 独立。
3. 在「配置出款」上传应用私钥、应用公钥证书、支付宝公钥证书和支付宝根证书，填写 APPID 和单笔限额，再启用付款。默认关闭，默认单笔限额 100 元。
4. 从「对接信息」获取已有商户 PID。付款接口仅供业务系统服务端调用。

出款证书与现有收款配置分别管理，可以使用不同的支付宝应用。服务端校验证书有效期、私钥与应用证书的匹配关系，自动计算证书序列号。实际产品权限、业务场景和额度由支付宝校验。当前仅支持转账到支付宝登录账号（邮箱或手机号），不支持银行卡。

## 接口和签名

| 接口 | 用途 |
| --- | --- |
| `POST /api/v1/payouts` | 受理付款，返回 202；相同订单重复请求返回 200 |
| `POST /api/v1/payouts/query` | 按业务提现单号查询，返回 200 |

请求为 UTF-8 JSON，最大 64 KiB。金额字段为整数分，禁止浮点金额。所有请求使用以下 HTTP 头：

- `X-Unipay-Pid`：商户 PID。
- `X-Unipay-Timestamp`：Unix 秒级时间戳，允许前后 300 秒偏差。
- `X-Unipay-Nonce`：每次 HTTP 请求生成不同的 16～64 位字母、数字、`_` 或 `-` 字符串。
- `X-Unipay-Signature`：下述字符串的 HMAC-SHA256，小写十六进制。

签名字符串各行以单个 LF（`\n`）连接，最后一行后**没有换行**：

```text
unipay-payout-v1
<PID>
<TIMESTAMP>
<NONCE>
POST
<PATH>
<SHA256_HEX_OF_RAW_BODY>
```

PATH 是上述固定路径，不含域名和查询字符串。先生成实际发送的 JSON 字节，再对这些字节做 SHA256；不要签名后重新序列化 JSON。**HMAC 密钥使用后台显示的 64 个十六进制字符的 UTF-8 字节，不要 hex-decode。** 签名覆盖方法、路径、PID、时间、nonce 和请求正文。

每个商户限流 30 次付款/查询请求每分钟。每次重试使用新的 timestamp/nonce/签名，但必须保持原业务提现单号和付款参数。

## 发起付款

```json
{
  "out_biz_no": "withdrawal-20260909-0001",
  "amount_cents": 1234,
  "payee_account": "recipient@example.com",
  "payee_name": "收款人实名",
  "title": "用户提现",
  "notify_url": "https://business.example.com/callbacks/payout",
  "transfer_scene_name": "按支付宝签约填写的真实业务场景",
  "transfer_scene_report_infos": [
    {"info_type": "场景要求的信息类型", "info_content": "真实业务信息"}
  ]
}
```

场景名称和上报字段必须按商户签约产品要求填写，示例中的占位文字不能直接用于实付。`transfer_scene_report_infos` 默认为空数组；具体场景要求上报时必须填写。

除 `transfer_scene_report_infos` 外，上述字段全部必填；不接受未知字段。文本字段禁止首尾空白和控制字符。字段限制如下：

| 字段 | 限制 |
| --- | --- |
| `amount_cents` | 正整数分，最多 100000000，且不超过商户配置的单笔限额 |
| `payee_account` / `payee_name` / `title` | 各 1～100 个字符 |
| `transfer_scene_name` | 1～64 个字符 |
| `notify_url` | 最多 2048 UTF-8 字节，公网 HTTPS，不允许用户信息或 URL 片段 |
| `transfer_scene_report_infos` | 最多 10 项；每项必填 `info_type`（1～64 字符）和 `info_content`（1～300 字符） |

`out_biz_no` 是业务系统的提现单号，1～64 位字母、数字、`_` 或 `-`。UniPay 生成独立的全局 `payout_no`，并将它作为支付宝的 `out_biz_no`，防止不同商户共用一个支付宝应用时发生单号碰撞。

同一商户、同一 `out_biz_no` 永远对应同一笔付款。参数全部相同时返回原记录；更换金额、收款人、通知地址或其他字段时返回 409，不创建新付款。返回结果示例：

```json
{
  "payout_no": "P01EXAMPLE",
  "out_biz_no": "withdrawal-20260909-0001",
  "amount_cents": 1234,
  "status": "queued",
  "channel_order_id": null,
  "failure_code": null,
  "created_at": "2026-09-09T00:00:00Z",
  "completed_at": null
}
```

## 查询与余额处理

查询请求：`{"out_biz_no":"withdrawal-20260909-0001"}`，响应结构与发起付款一致。查询返回 UniPay 的持久化状态，后台独立向支付宝查单。

| 状态 | 含义 | 业务系统处理 |
| --- | --- | --- |
| `queued` | 已落库，尚未发送给支付宝 | 保持冻结 |
| `processing` | 已开始发送，最终结果尚未确认 | 保持冻结，继续查询 |
| `succeeded` | 支付宝签名结果确认成功 | 完成提现扣款，仅处理一次 |
| `failed` | 确定未打款成功 | 解冻余额，仅处理一次 |

202 仅表示受理，HTTP 请求成功不等于付款成功。客户端超时、5xx、验签失败、支付宝系统异常、重复受理和查无此单均不能当作付款失败。用原单号查询或重试原受理请求，切勿另起新单。

`failure_code` 在 `processing` 时只是诊断信息（例如 `RESULT_UNKNOWN`、`ORDER_NOT_EXIST`），不能作为解冻依据；只有终态 `failed` 才表示明确失败。公开结果不包含收款账号和姓名。

## 结果通知

终态 `succeeded` / `failed` 触发 HTTPS POST JSON 通知。正文是查询结果，加上 `pid`。验签所需的 PID、timestamp、nonce、signature 与请求使用同名头；另有 `X-Unipay-Key-Id`，为通知密钥 UTF-8 字节 SHA256 的前 12 位十六进制，用于选择轮换前后的密钥。

通知签名字符串使用独立前缀，各行 LF 分隔，末尾无换行：

```text
unipay-payout-notify-v1
<PID>
<TIMESTAMP>
<NONCE>
<SHA256_HEX_OF_RAW_BODY>
```

业务系统先验签并核对 PID、订单和金额，在数据库事务中按业务提现单号幂等处理余额，成功持久化后返回 HTTP 2xx 和**字节完全为 `success` 的响应正文**。不要仅依据通知参数改余额。通知至少一次投递，重复通知和主动查询可能同时到达；两者应使用同一个幂等终态处理入口。

第一次投递立即安排，失败后间隔 1 分钟、5 分钟、15 分钟、1 小时，之后每 6 小时重试，总计最多 10 次。重试耗尽后可在付款记录中「重发通知」，这只重发结果，不会再次转账。投递计划和记录持久化，重启后继续。

通知只允许公开 HTTPS 地址；拒绝私网、回环、元数据地址及域名解析到这些地址的目标，不跟随重定向，也不使用环境代理。回调端点需要直接返回结果。请为业务系统提供公网可达的 HTTPS 回调入口。

重置付款密钥后，新请求立即使用新密钥；已受理订单的通知继续使用受理时的密钥。业务系统应保留旧通知验签密钥，直到这些订单的通知全部处理完毕。

## Python 接入示例

下面的函数只定义接口调用方式，不会自动发起付款。密钥从进程环境读取，不在命令行或日志中输出。

```python
import hashlib
import hmac
import json
import os
import secrets
import time
import urllib.request


def payout_request(path, payload):
    base_url = os.environ["UNIPAY_URL"].rstrip("/")
    pid = os.environ["UNIPAY_PID"]
    key = os.environ["UNIPAY_PAYOUT_KEY"].encode("utf-8")
    body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
    timestamp = str(int(time.time()))
    nonce = secrets.token_hex(16)
    canonical = "\n".join([
        "unipay-payout-v1", pid, timestamp, nonce, "POST", path,
        hashlib.sha256(body).hexdigest(),
    ])
    signature = hmac.new(key, canonical.encode("utf-8"), hashlib.sha256).hexdigest()
    request = urllib.request.Request(base_url + path, data=body, method="POST", headers={
        "Content-Type": "application/json",
        "X-Unipay-Pid": pid,
        "X-Unipay-Timestamp": timestamp,
        "X-Unipay-Nonce": nonce,
        "X-Unipay-Signature": signature,
    })
    # Forbid redirects: do not forward signed instructions to another target.
    class NoRedirect(urllib.request.HTTPRedirectHandler):
        def redirect_request(self, req, fp, code, msg, headers, newurl):
            return None
    with urllib.request.build_opener(NoRedirect()).open(request, timeout=15) as response:
        return json.load(response)


def verify_payout_notification(headers, raw_body, expected_pid, key):
    # key must be selected from your key store using X-Unipay-Key-Id.
    try:
        pid = headers["X-Unipay-Pid"]
        timestamp = headers["X-Unipay-Timestamp"]
        nonce = headers["X-Unipay-Nonce"]
        signature = headers["X-Unipay-Signature"]
        if pid != expected_pid or abs(int(time.time()) - int(timestamp)) > 300:
            return False
        canonical = "\n".join([
            "unipay-payout-notify-v1", pid, timestamp, nonce,
            hashlib.sha256(raw_body).hexdigest(),
        ])
        expected = hmac.new(key.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
        return hmac.compare_digest(expected, signature)
    except (KeyError, TypeError, ValueError):
        return False
```

业务系统还需在验签后持久化通知幂等记录；这个验签函数本身不处理余额，也不代替订单核对。

## 错误与运维

| HTTP 状态 | 含义 |
| --- | --- |
| 400 | 参数无效、超过单笔限额或配置不完整 |
| 401 | 密钥、签名、时间戳或商户状态无效 |
| 403 | 付款关闭、商户禁用或没有对应后台权限 |
| 404 | 当前商户查不到该付款 |
| 409 | nonce 重复，或同一业务单号对应不同参数 |
| 413 | 请求正文超过 64 KiB |
| 429 | 请求过于频繁 |
| 5xx / 网络超时 | 受理或查询结果不确定；按原单号查询/重试 |

出款配置、API 密钥、通知密钥及含收款人信息的请求均加密保存。每笔订单绑定不可变的出款配置版本，配置更新不会将旧订单误查到新支付宝应用。禁用出款阻止新付款及尚未发送的订单，已发送订单继续查单和通知。

任务在发送前持久化 `processing`，避免重启或并发造成二次发送。若恰好在写入该状态后、请求真正发送前进程崩溃，该订单也会保留 `processing` 并持续查单；系统不会自动补发转账或因「查无此单」解冻余额。长期未确认的订单需人工用 `payout_no` 到支付宝核对，确认资金事实后在业务系统处理。UniPay 不提供强制成功/失败或人工重复打款按钮。

当前自动查询间隔逐渐增加到 5 分钟，非终态不会自动超时改为失败。通知最多重试 10 次，后台支持手动重发。数据库备份必须包含新增 payout 表，更新时保持原 `UNIPAY_MASTER_KEY`。

该功能的自动化验证使用合成测试密钥和模拟支付宝网关；首次正式出款前，应配置实际签约应用，在业务系统中发起可核对的小额提现完成联调。
