网上讲易支付 API 对接的文章不少,但大多只给你一个"下单 + 回调"的最小闭环,跑通第一笔就收工了。可真到业务里你会发现,光会下单远远不够——用户付了钱但你的回调没收到,你怎么主动去查这笔单子的状态?用户要退款,你怎么调退款接口?月底对账,账对不上了,你怎么用对账接口把明细拉回来?
这篇不重复那些"半小时跑通第一笔"的入门内容,而是给你一份真正能上生产的完整指南:完整的参数表(不只下单接口)、MD5 和 RSA 两套签名算法、以及一个可以直接拿去用的 PHP SDK 类,把下单、主动查单、退款、对账四个接口一次性封装好。照着抄,能直接接进你的项目。
先立个规矩:接口对接,别只信回调
这是我要放在最前面讲的,因为它决定了你后面所有代码怎么写。
回调(异步通知)是靠得住的,但不是绝对靠得住。网络抖动、你服务器重启、WAF 误拦、易支付那边重试策略到期,都可能导致一笔"用户确实付了钱"的订单,你的系统却一直显示"未支付"。如果这时候你只会傻等回调,这笔订单就烂在库里了。
所以完整的对接方案,必须同时具备两条腿:被动收回调 + 主动查订单。回调来了就处理,回调没来就主动查。下面我要给的 SDK,把这两个能力都装进去了。
完整参数表:四个接口一次说清
易支付的接口不只一个下单。你日常会用到的有四个,我把每个的核心参数列清楚。
1. 下单接口(submit.php)——发起一笔支付。核心参数:pid(商户ID)、type(alipay/wxpay)、out_trade_no(商户订单号)、notify_url(异步回调)、return_url(同步回跳)、name(商品名)、money(金额)、sign、sign_type。返回一个支付跳转地址。
2. 主动查单接口(api.php?act=order)——用商户订单号查一笔订单的支付状态。核心参数:pid、out_trade_no、sign。返回里有关键字段 status,1 表示已支付。
3. 退款接口(api.php?act=refund)——对已支付的订单发起退款。核心参数:pid、out_trade_no、money(退款金额,可部分退)、sign。注意退款金额不能大于订单金额,且通常要传原订单号。
4. 订单对账接口(api.php?act=orders)——按时间区间拉取订单明细,用于月底对账。核心参数:pid、start(开始时间戳)、end(结束时间戳)、page、sign。
这里有个容易踩的点:四个接口的签名算法是同一套,但参数集不一样。很多人下单的签名算对了,查单却报签名错误,就是因为查单接口少传了 type、name 这些字段,签名串的拼接结果自然对不上。签名算法本身没变,变的是"哪些参数参与签名"。
签名算法:MD5 和 RSA,选哪个、怎么算
签名就两种主流:MD5 和 RSA。很多人纠结选哪个,我先给结论:个人或小商户,用 MD5 完全够用;企业级、对资金安全要求高的场景,上 RSA。MD5 的密钥是一串字符串,丢了或泄露了别人就能伪造签名;RSA 是公私钥对,私钥只在你手里,即使有人拿到了公钥也伪造不了。
MD5 的算法一句话:把除 sign、sign_type 外的所有参数,按参数名 ASCII 升序排序,拼成 key=value 用 & 连接,末尾拼上商户密钥,整体做 MD5。空值不参与。
RSA 的算法区别在于:排序拼接那一步和 MD5 完全一样,只是最后不是做 MD5,而是用商户私钥对签名串做 SHA256 摘要后再 RSA 私钥签名,结果是 Base64。所以你会发现,RSA 和 MD5 的"参数组装"部分可以共用同一段代码,只是最后一步的"签名动作"不同。
下面这个 SDK 类,把两种签名都封装好了,下单、查单、退款、对账四个方法齐全,可以直接抄:
class EpayClient {
private $pid;
private $key; // MD5 用商户密钥,RSA 用私钥
private $gateway; // 网关地址,如 https://你的域名/
private $signType; // 'MD5' 或 'RSA'
public function __construct($config) {
$this->pid = $config['pid'];
$this->key = $config['key'];
$this->gateway = rtrim($config['gateway'], '/') . '/';
$this->signType = $config['sign_type'] ?? 'MD5';
}
// 组装签名串:排序 + 拼接(MD5 和 RSA 共用)
private function buildSignStr($params) {
ksort($params);
$str = '';
foreach ($params as $k => $v) {
if ($v === '' || $v === null) continue;
$str .= $k . '=' . $v . '&';
}
return rtrim($str, '&');
}
// 计算签名
private function sign($params) {
$str = $this->buildSignStr($params);
if ($this->signType === 'RSA') {
$key = openssl_get_privatekey($this->key);
openssl_sign($str, $signature, $key, OPENSSL_ALGO_SHA256);
return base64_encode($signature);
}
return md5($str . $this->key); // MD5:末尾拼密钥
}
// 验证回调签名
public function verify($params) {
$sign = $params['sign'];
unset($params['sign'], $params['sign_type']);
if ($this->signType === 'RSA') {
$key = openssl_get_publickey($this->key); // RSA 验签用公钥
$ok = openssl_verify($this->buildSignStr($params),
base64_decode($sign), $key, OPENSSL_ALGO_SHA256);
return $ok === 1;
}
return md5($this->buildSignStr($params) . $this->key) === $sign;
}
// 下单
public function createOrder($order) {
$params = [
'pid' => $this->pid,
'type' => $order['type'],
'out_trade_no' => $order['out_trade_no'],
'notify_url' => $order['notify_url'],
'return_url' => $order['return_url'],
'name' => $order['name'],
'money' => number_format($order['money'], 2, '.', ''),
];
$params['sign'] = $this->sign($params);
$params['sign_type'] = $this->signType;
return $this->gateway . 'submit.php?' . http_build_query($params);
}
// 主动查单
public function queryOrder($outTradeNo) {
$params = [
'pid' => $this->pid,
'out_trade_no' => $outTradeNo,
];
$params['sign'] = $this->sign($params);
$params['sign_type'] = $this->signType;
return $this->request('api.php?act=order', $params);
}
// 退款
public function refund($outTradeNo, $money) {
$params = [
'pid' => $this->pid,
'out_trade_no' => $outTradeNo,
'money' => number_format($money, 2, '.', ''),
];
$params['sign'] = $this->sign($params);
$params['sign_type'] = $this->signType;
return $this->request('api.php?act=refund', $params);
}
// 对账
public function orders($start, $end, $page = 1) {
$params = [
'pid' => $this->pid,
'start' => $start,
'end' => $end,
'page' => $page,
];
$params['sign'] = $this->sign($params);
$params['sign_type'] = $this->signType;
return $this->request('api.php?act=orders', $params);
}
private function request($path, $params) {
$ch = curl_init($this->gateway . $path);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$resp = curl_exec($ch);
curl_close($ch);
return json_decode($resp, true);
}
}
用法很简单,初始化一个客户端,然后四个方法各管一件事:
$client = new EpayClient([
'pid' => '你的商户ID',
'key' => '你的商户密钥或RSA私钥',
'gateway' => 'https://你的域名',
'sign_type' => 'MD5',
]);
// 下单
$payUrl = $client->createOrder([
'type' => 'alipay',
'out_trade_no' => '202609120001',
'notify_url' => 'https://你的域名/notify',
'return_url' => 'https://你的域名/return',
'name' => '测试商品',
'money' => 9.90,
]);
// 主动查单
$result = $client->queryOrder('202609120001');
// $result['status'] == 1 表示已支付
主动查单:你真正的兜底手段
上面那个 queryOrder 方法,是很多对接教程根本不提、但实际最救命的。
正确的用法是配合一个定时任务:每隔几分钟,把那些"创建了超过 N 分钟但还没收到回调、状态仍是未支付"的订单扫一遍,逐个调 queryOrder 查真实状态。查出来已支付的,就走和回调一样的发货逻辑,并把订单标记好。
这里有个关键细节:主动查单查到"已支付"后,发货逻辑要和回调发货用同一个幂等入口。否则会出现"回调先发货了,查单又发一次"的重复发货。判断依据还是订单号,发货前先查一遍"这个订单发没发过"。
另一个细节是频率。查单接口不能太频繁地打,否则容易被限流或触发风控。生产环境我建议:5 分钟内的订单不查,5 分钟到 1 小时内的订单每 5 分钟查一次,超过 1 小时还查不到的就人工介入。这样既兜得住底,又不会把接口打爆。
退款和对账:两个容易被忽略的收尾
退款接口要注意两点。第一,退款金额可以部分退,但不能超过原订单金额,传多了会被拒。第二,退款是异步的,调完接口返回"受理成功"不代表钱已经退到用户账上,实际到账要等银行或第三方渠道处理,一般是几分钟到一两天。
对账接口是用来月底平账的。把对账接口拉回来的明细,和你自己库里的订单一条条对,重点看三个字段:out_trade_no(能不能对上号)、money(金额是否一致)、status(状态是否一致)。对不上的,大概率是这两种情况:要么是回调丢了、你这边状态没更新;要么是金额精度问题,比如 9.9 元在两边被存成了不同的浮点数。
金额精度这个问题,我再强调一次:所有金额在程序里都用"分"(整数)来存和比,只在展示和传参时才转成"元"。浮点数比较是支付对账里的老毛病,一踩一个准。
三个能卡你半天的翻车点
最后把三个最隐蔽、最容易踩的坑给你排了。
第一个:商户密钥末尾的换行符。从控制台复制密钥时,末尾经常粘进去一个看不见的 \n,导致签名永远对不上。排查方法是在服务器上 od -c 看一下密钥文件结尾,或者直接 trim() 一下再存。
第二个:RSA 私钥和公钥混用。RSA 模式下,签名用私钥,验签用公钥,这俩方向千万不能反。很多人在 SDK 里把私钥同时用于签名和验签,结果自己签的自己验不过,还以为是算法写错了。如果你用的是 RSA,签名算法和 MD5 的差异、公私钥到底怎么配,我在支付回调sign签名:MD5和RSA到底有什么区别、该用哪个里拆得很细。
第三个:参数编码不一致。签名串里的参数,和最后实际发送的参数,必须是同一份。如果你在签名时对某个中文参数做了 urlencode,但发送时用了原始值,或者反过来,签名就会对不上。中文商品名尤其容易出这个问题,统一处理编码,别这边一套那边一套。
关于 MD5 签名里参数排序、空值处理那些更细的坑,我在MD5签名完整解析:参数组装、排序、空值处理,各类签名报错根源里单独写过一篇,签名报错死活调不通的时候去翻。
接口对接这件事,跑通第一笔只是开始。真正决定你后面省不省心的,是回调丢了能不能主动查回来、退款能不能调、月底对账能不能平。把下单、查单、退款、对账这四个接口都吃透,再用上面这个 SDK 把它们封装成你自己的工具,比背一百遍参数表都管用。