php代付源码 - 完整解决方案与实战教程
在涉及资金流转的第三方支付系统中,“php代付源码”最常见的业务场景是:平台需要将用户账户余额或商户结算款,通过API自动打款到指定的银行卡或支付宝账户。然而,很多开发者在初次接触代付源码时,往往只关注“能不能发起请求”,却忽略了并发锁、状态回查、签名验证以及异步通知的幂等性,导致出现“钱扣了但对方没收到”、“重复打款”、“签名失败导致资金被冻结”等严重生产事故。本文将从真实排坑角度出发,带你彻底掌握一套可落地的PHP代付源码核心逻辑。
问题现象:代付请求发出后,订单状态混乱与资金损失
在实际部署php代付源码后,你可能会遇到以下典型现象:
- 现象一:调用代付API返回“处理中”,但数据库订单状态一直未更新,最终用户未到账,而平台余额已被扣减。
- 现象二:上游通道异步通知重复发送,导致同一笔代付订单被多次标记为“成功”,财务对账时发现重复出款。
- 现象三:使用RSA签名时,本地测试通过,上线后上游返回“签名错误”,排查发现是参数排序或URL编码不一致。
- 现象四:高并发下,同一商户号同时发起多笔代付,部分请求因未加锁导致余额超扣。
原因分析:为什么你的php代付源码会“跑偏”
上述现象背后,通常不是PHP语言本身的问题,而是架构设计缺失。具体原因可归纳为以下几点:
- 缺少状态机与主动查单:代付并非即时到账,很多通道是“T+0”或“T+1”异步处理。源码中只依赖同步返回,没有定时任务去主动查询订单状态,导致状态悬挂。
- 未处理异步通知的幂等性:上游通道为了保证通知到达,会多次发送POST请求。如果你的php代付源码没有在数据库层做唯一索引或状态判断,就会重复更新。
- 签名逻辑不严谨:代付接口通常要求
sign参与排序,且空值不参与签名。很多源码直接使用http_build_query,导致空格被编码为+,而对方要求%20。 - 并发控制缺失:没有使用Redis或MySQL行锁对“商户余额”进行扣减保护,导致并发请求下余额被多次扣减。
解决方案(附完整代码):构建高可用的PHP代付核心
下面给出一套经过生产验证的php代付源码核心片段,涵盖签名生成、请求发起、异步通知验签与幂等处理。代码基于PHP 7.4+,使用cURL和Redis。
1. 代付请求发起与签名(核心类)
<?php
/**
* 代付核心类 - 处理签名、请求、验签
* 适用于大多数第三方代付通道(如支付宝、微信、银行直连)
*/
class PaymentProxy
{
private $merchantId;
private $privateKey;
private $gatewayUrl;
private $redis;
public function __construct($config)
{
$this->merchantId = $config['merchant_id'];
$this->privateKey = $config['private_key'];
$this->gatewayUrl = $config['gateway_url'];
// 使用Redis做并发锁和幂等控制
$this->redis = new Redis();
$this->redis->connect('127.0.0.1', 6379);
}
/**
* 生成签名(RSA2)
* 注意:参数必须按key字典序排序,空值不参与签名
*/
public function generateSign(array $params): string
{
ksort($params);
$signStr = '';
foreach ($params as $k => $v) {
if ($v === '' || $v === null) {
continue;
}
$signStr .= $k . '=' . $v . '&';
}
$signStr = rtrim($signStr, '&');
$res = openssl_get_privatekey($this->privateKey);
openssl_sign($signStr, $sign, $res, OPENSSL_ALGO_SHA256);
openssl_free_key($res);
return base64_encode($sign);
}
/**
* 发起代付请求
* @param array $order 订单数据
* @return array
*/
public function submit(array $order): array
{
// 1. 并发锁:防止同一商户同时扣款
$lockKey = 'proxy_lock_' . $this->merchantId;
if (!$this->redis->set($lockKey, 1, ['nx', 'ex' => 5])) {
throw new Exception('操作过于频繁,请稍后重试');
}
try {
// 2. 组装公共参数
$params = [
'merchant_id' => $this->merchantId,
'out_trade_no' => $order['out_trade_no'],
'amount' => $order['amount'],
'bank_card' => $order['bank_card'],
'real_name' => $order['real_name'],
'notify_url' => 'https://yourdomain.com/notify.php',
'timestamp' => time(),
];
// 3. 生成签名
$params['sign'] = $this->generateSign($params);
// 4. 发送请求(使用cURL)
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $this->gatewayUrl,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($params),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new Exception('上游通道请求失败,HTTP码:' . $httpCode);
}
$result = json_decode($response, true);
if (empty($result['code']) || $result['code'] !== 'SUCCESS') {
throw new Exception('代付请求被拒绝:' . ($result['msg'] ?? '未知错误'));
}
return $result;
} finally {
// 释放锁
$this->redis->del($lockKey);
}
}
}
2. 异步通知处理(幂等与验签)
<?php
/**
* notify.php - 接收上游异步通知
* 关键点:验签 + 数据库唯一索引 + 状态判断
*/
require_once 'PaymentProxy.php';
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
if (empty($data['sign'])) {
exit('fail: missing sign');
}
// 1. 验签(使用上游公钥)
$publicKey = file_get_contents('/path/to/upstream_public.pem');
$sign = $data['sign'];
unset($data['sign']);
ksort($data);
$signStr = urldecode(http_build_query($data));
$verify = openssl_verify($signStr, base64_decode($sign), $publicKey, OPENSSL_ALGO_SHA256);
if ($verify !== 1) {
exit('fail: sign verify failed');
}
// 2. 幂等处理:利用数据库唯一索引或Redis setnx
$orderNo = $data['out_trade_no'];
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$idempotentKey = 'proxy_notify_' . $orderNo;
if (!$redis->set($idempotentKey, 1, ['nx', 'ex' => 3600])) {
// 已经处理过,直接返回success,避免重复通知
exit('success');
}
// 3. 更新订单状态(此处应使用事务和行锁)
$pdo = new PDO('mysql:host=localhost;dbname=payment', 'user', 'pass');
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("SELECT status FROM proxy_orders WHERE out_trade_no = ? FOR UPDATE");
$stmt->execute([$orderNo]);
$order = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$order) {
throw new Exception('订单不存在');
}
if ($order['status'] === 'success') {
// 已成功,直接提交并返回
$pdo->commit();
exit('success');
}
if ($data['status'] === 'SUCCESS') {
$update = $pdo->prepare("UPDATE proxy_orders SET status = 'success', trade_no = ?, update_time = NOW() WHERE out_trade_no = ?");
$update->execute([$data['trade_no'], $orderNo]);
} else {
$update = $pdo->prepare("UPDATE proxy_orders SET status = 'failed', remark = ?, update_time = NOW() WHERE out_trade_no = ?");
$update->execute([$data['msg'] ?? '', $orderNo]);
}
$pdo->commit();
echo 'success';
} catch (Exception $e) {
$pdo->rollBack();
// 记录日志,返回fail让上游重试
error_log('Notify error: ' . $e->getMessage());
echo 'fail';
}
3. 排查与配置清单(部署前必查)
- 证书路径:确保
private_key和上游public_key使用绝对路径,且PHP进程有读取权限。 - 签名算法:确认上游要求的是RSA还是RSA2,SHA256还是SHA1,必须严格一致。
- URL编码:在验签时,务必使用
urldecode处理http_build_query的结果,否则空格会变成+。 - Redis连接:确保Redis服务已启动,且PHP安装了
redis扩展。若没有Redis,可用MySQL唯一索引替代幂等键。 - 超时设置:cURL的
CURLOPT_TIMEOUT建议设为10-15秒,避免因上游延迟导致PHP进程阻塞。 - 日志记录:所有代付请求和通知必须记录原始报文,方便对账和排查。
总结来说,一套可靠的php代付源码,核心不在于“能发请求”,而在于状态可控、并发安全、幂等严谨。按照上述代码和排查清单落地,能帮你避开90%以上的资金损失坑。如果你正在对接多个上游通道,建议将签名和验签逻辑抽象为接口,方便扩展。