# UniPay 对接指南

UniPay 提供易支付 v1 收款和独立的支付宝付款接口。收款资金直接进入商户自己的支付宝账户；付款从商户自己的支付宝账户转出。用户余额、订单履约和提现审核由接入的业务系统管理。

本文与运行中的服务一同发布，无需登录即可读取。接口地址以当前部署域名为准，以下使用 `https://pay.example.com` 作为占位域名。

## 快速接入

1. 登录[商户后台](/admin)，在「对接信息」获取网关地址、商户 PID 和收款密钥。完整密钥仅在生成或重置时显示，指纹不能用于签名。
2. 在「渠道管理」配置自己的支付宝应用，并启用已签约的当面付、电脑网站支付或手机网站支付产品。
3. 已支持易支付 v1 的系统（如使用 go-epay 的 new-api、Sub2API）填写网关、PID、收款 key，支付方式选择 `alipay`。若配置项要求基础地址，填域名；若要求提交地址，填完整 `/submit.php`，避免重复拼接路径。
4. 自行开发时，在业务服务端创建本地订单并签名，再让买家浏览器打开签名后的支付地址或提交表单。
5. 实现异步通知验签、订单和金额核对、数据库幂等入账，再返回 `success`；使用主动查单补偿未收到的通知。

| 配置 | 说明 |
| --- | --- |
| 网关基础地址 | 当前 UniPay 部署域名，不带末尾 `/` |
| 收款提交 | `GET /submit.php` 或表单 `POST /submit.php` |
| 收款查询 | `GET /api.php?act=order`，仅供服务端调用 |
| `pid` | 商户 PID，按字符串处理；不是支付宝 APPID |
| 收款 `key` | 易支付收款签名和查单使用的密钥，仅放在业务服务端 |
| 付款 API 密钥 | 在「付款记录」单独生成，与收款 key 不通用 |

公开文档和示例不包含真实密钥。向 AI 提供[完整文档](/llms-full.txt)即可；真实密钥通过部署环境注入，勿放入聊天、前端代码、仓库或日志。

## 接口一览

| 方法与路径 | 协议 / 认证 | 结果 |
| --- | --- | --- |
| `GET /submit.php` | 易支付参数 + MD5 | 302 跳转收银台 |
| `POST /submit.php` | URL 编码表单 + MD5，不是 JSON | 302 跳转收银台 |
| `GET /api.php` | `act=order` + PID + 收款 key | JSON，检查 `code` 与 `trade_status` |
| `POST /api/v1/payouts` | JSON + HMAC-SHA256 请求头 | 202 受理；相同单据重试返回 200 |
| `POST /api/v1/payouts/query` | JSON + HMAC-SHA256 请求头 | 查询付款持久化状态 |
| `GET /api/v1/healthz` | 无认证 | `{"status":"ok"}`，仅表示进程存活 |

完整字段定义可下载 [OpenAPI 3.1 JSON](/openapi.json)。本文只描述商户业务系统需要调用的接口；后台管理接口使用独立登录会话。

## 收款：创建订单

请求为 UTF-8。GET 使用查询参数；POST 使用 `Content-Type: application/x-www-form-urlencoded`。

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `pid` | 是 | 商户 PID |
| `type` | 是 | 推荐 `alipay`；`alipay_page` 指定电脑网站支付，`alipay_wap` 指定手机网站支付 |
| `out_trade_no` | 是 | 业务系统生成的非空订单号，同一商户内唯一 |
| `notify_url` | 是 | 业务系统接收异步 GET 通知的公网地址，推荐 HTTPS |
| `return_url` | 是 | 买家支付完成后返回的业务页面地址 |
| `name` | 是 | 非空商品名称 |
| `money` | 是 | 正数人民币元字符串，如 `"12.34"`；最多两位小数，不接受科学计数法 |
| `sign` | 是 | 下节算法生成的 32 位 MD5 十六进制字符串 |
| `sign_type` | 按协议发送 | 固定 `MD5` |

`type=alipay` 会按商户已启用产品选择收银方式，手机浏览器优先使用已启用的 WAP；显式指定的产品需已配置并启用。当前不支持微信支付、退款接口或其他易支付 `act`。

成功返回 **HTTP 302**，`Location: /cashier/{trade_no}`。这是交给买家浏览器的收银台地址，不是 JSON API。服务器收到 302 不代表已经付款。

订单有效期为 15 分钟。同一商户重复提交同一 `out_trade_no`：待支付订单复用原订单和参数；过期或失败订单会以本次参数重新激活；已支付订单返回 400 `order already paid`。业务系统应保持订单参数稳定，修改商品或金额时使用新的业务单号。

## 收款：MD5 签名

1. 从全部参数中移除 `sign`、`sign_type` 和值为空字符串的项。额外发送的非空参数也参与签名。
2. 按参数名 ASCII 升序排序，使用**原始参数值**拼成 `k1=v1&k2=v2`。不要先 URL 编码，不要去掉值中的空格。
3. 在末尾直接追加原始收款 key，不加 `&key=` 或其他分隔符。
4. 对整个字符串的 UTF-8 字节计算 MD5，输出小写十六进制。
5. 添加 `sign` 和 `sign_type=MD5` 后，才对参数做表单或 URL 编码。

```text
sign = MD5_HEX(UTF8(sorted_nonempty_parameters + epay_key))
```

以下是可公开使用的合成签名测试向量，不是真实商户凭据：

```json
{
  "params": {"pid": "1001", "money": "1.00", "name": "topup", "type": "alipay", "out_trade_no": "20260707001", "notify_url": "https://a.com/n", "return_url": "https://a.com/r", "sign_type": "MD5", "empty": ""},
  "test_key": "testkey123",
  "expected_sign": "e1a8ad540d38032abe568da728c12c1f",
  "canonical_without_key": "money=1.00&name=topup&notify_url=https://a.com/n&out_trade_no=20260707001&pid=1001&return_url=https://a.com/r&type=alipay"
}
```

## 收款：Python 示例

标准库示例只定义函数，不会自动创建订单或发送支付请求。`UNIPAY_URL`、`UNIPAY_PID`、`UNIPAY_EPAY_KEY` 由业务服务端环境提供。

```python
import hashlib
import hmac
import json
import os
import urllib.parse
import urllib.request


def epay_sign(params, key):
    # Protocol values must be strings. "0" participates; only "" is empty.
    canonical = "&".join(
        f"{k}={params[k]}" for k in sorted(params)
        if k not in ("sign", "sign_type") and params[k] != ""
    )
    return hashlib.md5((canonical + key).encode("utf-8")).hexdigest()


def payment_url(out_trade_no, name, money, notify_url, return_url):
    params = {
        "pid": os.environ["UNIPAY_PID"], "type": "alipay",
        "out_trade_no": out_trade_no, "name": name, "money": money,
        "notify_url": notify_url, "return_url": return_url,
        "sign_type": "MD5",
    }
    params["sign"] = epay_sign(params, os.environ["UNIPAY_EPAY_KEY"])
    return os.environ["UNIPAY_URL"].rstrip("/") + "/submit.php?" + urllib.parse.urlencode(params)


def verify_payment_notification(params):
    # Parse query values once; reject duplicate parameter names in your HTTP handler.
    signature = params.get("sign", "")
    if len(signature) != 32 or any(c not in "0123456789abcdefABCDEF" for c in signature):
        return False
    expected = epay_sign(params, os.environ["UNIPAY_EPAY_KEY"])
    return (
        params.get("pid") == os.environ["UNIPAY_PID"]
        and params.get("trade_status") == "TRADE_SUCCESS"
        and hmac.compare_digest(expected, signature.lower())
    )


def query_payment(out_trade_no):
    params = {
        "act": "order", "pid": os.environ["UNIPAY_PID"],
        "key": os.environ["UNIPAY_EPAY_KEY"], "out_trade_no": out_trade_no,
    }
    url = os.environ["UNIPAY_URL"].rstrip("/") + "/api.php?" + urllib.parse.urlencode(params)
    class NoRedirect(urllib.request.HTTPRedirectHandler):
        def redirect_request(self, req, fp, code, msg, headers, newurl):
            return None
    # The URL includes a secret: do not log it or raw HTTP exception details.
    with urllib.request.build_opener(NoRedirect()).open(url, timeout=15) as response:
        return json.load(response)
```

验签函数只证明通知签名有效。随后必须在数据库事务中核对本地 `out_trade_no`、PID、金额和订单归属，并仅对尚未入账的订单执行一次业务处理。金额用整数分或十进制定点数比较，避免浮点误差。

## 收款：异步通知与页面返回

UniPay 确认付款后向 `notify_url` 发送 **GET**，包含以下签名参数：

| 字段 | 内容 |
| --- | --- |
| `pid` | 商户 PID |
| `trade_no` | UniPay 平台订单号 |
| `out_trade_no` | 业务订单号 |
| `type` / `name` | 原订单支付类型 / 商品名 |
| `money` | 人民币元字符串，固定两位小数 |
| `trade_status` | `TRADE_SUCCESS` |
| `sign` / `sign_type` | 收款 MD5 签名 / `MD5` |

验签规则与创建订单一致。通知地址建议不自带查询参数，避免业务自定义参数与签名参数混淆。完成验签、订单金额核对和持久化幂等入账后，返回 HTTP 200，正文仅为小写 `success`。处理失败时不要返回成功确认。

通知可能重复、延迟或先于买家返回到达。首次立即投递，失败后按 1 分钟、5 分钟、15 分钟、1 小时、6 小时间隔重试（当前最多 6 次投递）；后台支持手动重发。主动查询和回调应调用相同的幂等入账逻辑。

买家在收银台等待后端确认支付成功后，会看到三秒倒计时，再跳转 `return_url`，携带与通知相同的签名字段。页面返回用于展示订单状态，不能仅凭浏览器参数发货或充值；业务服务端应依据已验签的异步通知或认证查单结果确认入账。

## 收款：主动查单

```text
GET /api.php?act=order&pid=<PID>&key=<EPAY_KEY>&out_trade_no=<BUSINESS_ORDER_NO>
```

全部四个字段都要填写。当前实现**只按 `out_trade_no` 查询**，不支持用 `trade_no` 替代。此接口直接携带收款 key，仅可从服务端通过 HTTPS 调用；不要把 URL 放入浏览器、访问日志或错误上报。

成功响应示例：

```json
{
  "code": 1,
  "trade_no": "01EXAMPLE",
  "out_trade_no": "order-20260909-0001",
  "type": "alipay",
  "name": "账户充值",
  "money": "12.34",
  "trade_status": "TRADE_SUCCESS",
  "addtime": "2026-09-09 10:00:00",
  "endtime": "2026-09-09 10:01:00"
}
```

`code=1` 表示查到订单；仅 `trade_status=TRADE_SUCCESS` 表示支付成功。所有其他内部状态（包括过期、失败）在此接口均映射为 `WAIT_BUYER_PAY`，不能据此区分失败原因。`addtime`、`endtime` 为 UTC 时间，格式 `YYYY-MM-DD HH:mm:ss`；未付款时 `endtime` 为空字符串。

查单业务错误仍返回 **HTTP 200**，正文如 `{"code":-4,"msg":"order not found"}`：

| `code` | `msg` | 处理 |
| --- | --- | --- |
| `-1` | `key error` | 核对商户 PID 与收款 key |
| `-2` | `unsupported act` | 仅支持 `act=order` |
| `-4` | `order not found` | 核对当前商户的业务订单号，不要据此确认支付成功 |

## 收款：错误排查

| 场景 | 响应 / 处理 |
| --- | --- |
| 提交参数缺失 | HTTP 400，纯文本 `missing <field>` |
| PID 或签名错误 | HTTP 400，`unknown pid` / `bad sign`；检查原始值、排序、空值和 UTF-8 |
| 商户关闭 | HTTP 400，`merchant disabled` |
| 金额无效 | HTTP 400，`illegal money`；使用正数元字符串，最多两位小数 |
| 已支付单重复提交 | HTTP 400，`order already paid`；查单后幂等处理 |
| 请求过频 | HTTP 429；提交按 PID + 来源 IP 限制每分钟 30 次，退避重试 |
| 超时或 HTTP 5xx | 先用原业务单号查单；不可当作已付款或直接重复入账 |
| 通知未到 | 检查公网回调地址、验签和 `success` 响应，主动查单，必要时后台重发通知 |

付款接口的 HMAC 签名、整数分金额、幂等规则和终态通知见下方完整付款说明，或单独读取[付款 Markdown](/docs/payouts.md)。
