回调说明
当用户完成支付后,易支付网关会向授权服务器发送异步回调(notify_url)。本章节讲解回调的格式、签名验证、处理逻辑。
实际回调入口位于
/epay_notify.php(不是 /api/ 下),通过 GET 方式接收(兼容彩虹易支付、异次元、YPay 等通用网关)。
回调流程
┌──────────┐ 用户支付成功 ┌──────────┐ 异步 GET ┌──────────────┐
│ 用户 │ ──────────────▶ │ 易支付网关 │ ─────────────▶ │ License Server │
│ │ │ │ notify_url= │ /epay_notify. │
│ │ │ │ .../notify │ php │
└──────────┘ └──────────┘ └──────────────┘
│
│ 完成订单
│ 生成授权码
▼
┌──────────────┐
│ lic_orders │
│ lic_licenses │
└──────────────┘
回调地址
在创建订单时传给易支付的 notify_url 形如:
https://license.example.com/epay_notify.php
对应的 return_url(同步跳转)为:
https://license.example.com/epay_return.php
回调参数(GET)
| 参数 | 类型 | 说明 |
|---|---|---|
pid | int | 商户 ID(必须与后台 epay_pid 一致) |
trade_no | string | 易支付平台订单号 |
out_trade_no | string | 商户订单号(我们的 order_no) |
type | string | 支付方式:alipay / wxpay / qqpay / union |
name | string | 商品名称 |
money | string | 金额(字符串,如 "39.00") |
trade_status | string | 支付状态:TRADE_SUCCESS |
sign | string | 易支付签名 |
sign_type | string | 签名类型:MD5 / RSA |
签名验证
易支付使用 MD5 或 RSA 签名。验证步骤:
- 按 key 排序(排除
sign和sign_type) - 拼接成
key=value&key=value...形式 - 末尾追加
&key={商户密钥} - 计算 MD5,与回调里的
sign比对
实际项目使用 inc/functions.php 中的 epay_verify_sign(),密钥从设置表自动读取:
<?php
function epay_verify_sign($params) {
// 1. 排除 sign 和 sign_type
unset($params['sign'], $params['sign_type']);
// 2. 按 key 排序
ksort($params);
// 3. 拼接字符串
$sign_str = '';
foreach ($params as $key => $value) {
$sign_str .= $key . '=' . $value . '&';
}
// 4. 优先用 epay_key 验签,失败再尝试 epay2_key(兼容双通道)
$keys = [(string)get_setting('epay_key', '')];
if (get_setting('epay2_enabled', '0') === '1') {
$keys[] = (string)get_setting('epay2_key', '');
}
foreach ($keys as $epay_key) {
if ($epay_key === '') continue;
$expected = md5($sign_str . 'key=' . $epay_key);
if (hash_equals($expected, (string)($params['sign'] ?? ''))) {
return true;
}
}
return false;
}
七层安全校验
epay_notify.php 实现了完整的七层安全校验,任何一层失败都会返回 fail 让易支付重试:
- 签名验证:
epay_verify_sign()同时尝试第一/二通道密钥 - PID 校验:回调的
pid必须严格等于后台配置的epay_pid,防止跨商户攻击 - 状态校验:
trade_status必须严格等于TRADE_SUCCESS - 订单号非空:
out_trade_no和trade_no都必须非空 - 订单存在:
get_order_by_no()必须能找到 - 金额校验:
bccomp()精确到分比对回调金额和订单金额(防 0.01 元买 39 元) - 支付方式校验:回调的
type必须与订单的pay_type一致(防错通道触发)
幂等性
易支付可能多次回调(网络问题),所以必须幂等。实现采用 事务 + rowCount 双重幂等:
- 订单已是
paid/completed→ 直接返回success(不再重发) - 并发场景下用
UPDATE ... WHERE pay_status = 'pending'锁定,rowCount()为 0 表示被其他进程抢到,本进程直接返回success
数据库建议索引:
ALTER TABLE lic_orders ADD UNIQUE KEY uk_trade_no (trade_no);
处理逻辑(精简版)
<?php
// epay_notify.php
require_once __DIR__ . '/inc/functions.php';
$params = $_GET;
// 1-7. 七层安全校验
if (!epay_verify_sign($params)) { echo 'fail'; exit; }
if ($params['pid'] !== get_setting('epay_pid')) { echo 'fail'; exit; }
if ($params['trade_status'] !== 'TRADE_SUCCESS') { echo 'fail'; exit; }
$order = get_order_by_no($params['out_trade_no']);
if (!$order || $order['pay_status'] !== 'pending') { echo 'success'; exit; }
if (bccomp(number_format($order['amount'], 2, '.', ''),
number_format($params['money'], 2, '.', ''), 2) !== 0) { echo 'fail'; exit; }
// 8. 事务 + 幂等
$db = get_db();
$db->beginTransaction();
try {
$stmt = $db->prepare("UPDATE " . table('orders') . "
SET pay_status = 'paid', trade_no = ?, paid_at = NOW()
WHERE id = ? AND pay_status = 'pending'");
$stmt->execute([$params['trade_no'], $order['id']]);
if ($stmt->rowCount() === 0) {
// 被其他进程抢到了
$db->commit();
echo 'success';
exit;
}
// 9. 完成订单(生成授权码 / 续期)
$order['pay_status'] = 'paid';
$order['trade_no'] = $params['trade_no'];
complete_order($order);
$db->commit();
echo 'success';
} catch (Throwable $e) {
$db->rollBack();
error_log('epay_notify error: ' . $e->getMessage());
echo 'fail';
}
常见问题
| 问题 | 原因 | 解决 |
|---|---|---|
| 回调收不到 | 服务器防火墙拦截 | 在易支付白名单加上授权服务器 IP |
| 签名验证失败 | 商户密钥配置错误 | 核对 epay_key 是否与易支付商户后台一致 |
| 金额不匹配(fail) | 回调金额与订单金额小数位不一致 | 已用 bccomp 精确到分比对,确保订单创建时用字符串 "39.00" |
| 支付方式不匹配(fail) | 用户实际用支付宝,订单标记为微信 | 检查用户下单时选择的通道 |
| 订单已存在但未生成授权 | complete_order() 中途失败 |
查看 PHP 错误日志,定位 SQL 错误;事务已回滚可手动重试 |
订单生成授权的完整代码见 设计思路 → 订单生命周期。