# 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¬ify_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=&key=&out_trade_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 ` | | 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)。 --- # 支付宝付款接口 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 POST ``` 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、订单和金额,在数据库事务中按业务提现单号幂等处理余额,成功持久化后返回 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`。 该功能的自动化验证使用合成测试密钥和模拟支付宝网关;首次正式出款前,应配置实际签约应用,在业务系统中发起可核对的小额提现完成联调。