扫码支付的核心流程其实并不复杂:用户在收银台页面点击支付后,服务器向支付平台请求生成一个预付订单,拿到对应的二维码链接,前端把链接渲染成二维码让用户扫描。用户扫码完成付款后,支付平台会向商户服务器推送一条异步通知,服务器验签通过后更新订单状态,整个交易闭环就完成了。下面按照这个流程逐步拆解PHP端的实现细节。

支付前的准备:商户号、证书与参数配置
无论是微信支付还是支付宝,接入扫码支付的第一步都是在开放平台申请商户账号,拿到商户号(mch_id)、应用ID(appid)以及用于签名的API密钥。微信支付还需要下载商户证书放在服务器安全目录下,注意不要把证书放在Web可访问的目录,避免被直接下载造成资金风险。
建议在项目中单独建一个支付配置文件,把环境相关的参数集中管理。开发环境和生产环境的密钥务必分开,配置示例可以参考下面这段代码。这里的证书路径使用了Windows风格的绝对路径写法,实际部署到Linux服务器时改成对应路径即可,反斜杠只在Windows下使用。
<?php
// config/pay.php 支付配置文件
return [
'wechat' => [
'app_id' => 'wx1234567890abcdef',
'mch_id' => '1500000000',
'api_key' => '你的32位API密钥',
'cert_path' => 'C:\cert\apiclient_cert.pem',
'key_path' => 'C:\cert\apiclient_key.pem',
'notify_url' => 'https://www.ipipp.com/pay/notify.php',
],
];
?>配置写好后先做连通性测试,可以用最简单的接口调用验证密钥是否正确。很多初学者在这一步就卡住,常见报错是签名错误,多半是密钥填写有误或者参与签名的参数里包含了多余字段。
统一下单与二维码生成
扫码支付的主体逻辑是调用支付平台的统一下单接口。以微信Native支付为例,请求参数包括商品描述、商户订单号、总金额(单位为分)、回调地址等,接口返回一个code_url,把它拼接成二维码内容展示给用户即可。生成二维码可以使用phpqrcode类库,用composer安装也很方便。
下面是一个精简的下单处理代码。注意几个关键点:订单号要保证唯一性,一般用业务前缀加时间戳加随机数生成;金额单位是分不是元,下单前记得乘以100并取整;out_trade_no在商户号下必须唯一,重复提交会导致下单失败。
<?php
require_once 'vendor/autoload.php';
$config = require 'config/pay.php';
// 生成唯一订单号
$outTradeNo = 'ORDER' . date('YmdHis') . mt_rand(1000, 9999);
// 组装请求参数
$params = [
'appid' => $config['wechat']['app_id'],
'mch_id' => $config['wechat']['mch_id'],
'nonce_str' => mt_rand(100000, 999999),
'body' => '测试商品',
'out_trade_no' => $outTradeNo,
'total_fee' => 100, // 单位:分
'spbill_create_ip' => $_SERVER['REMOTE_ADDR'],
'notify_url' => $config['wechat']['notify_url'],
'trade_type' => 'NATIVE',
];
// 按ASCII码排序后拼接生成签名
ksort($params);
$signStr = '';
foreach ($params as $k => $v) {
$signStr .= $k . '=' . $v . '&';
}
$signStr .= 'key=' . $config['wechat']['api_key'];
$params['sign'] = strtoupper(md5($signStr));
// 转成XML并发起请求
$xml = ArrayToXml($params);
$response = curlPost('https://api.mch.weixin.qq.com/pay/unifiedorder', $xml);
$result = XmlToArray($response);
if ($result['return_code'] === 'SUCCESS' && $result['result_code'] === 'SUCCESS') {
$codeUrl = $result['code_url'];
// 使用phpqrcode生成二维码图片
QRcode::png($codeUrl, false, 'L', 8, 2);
exit;
}
throw new Exception('下单失败:' . $result['return_msg']);
?>如果不想手写这些拼接和签名逻辑,直接使用官方或社区维护的SDK会更稳妥,例如overtrue的easywechat,它封装了请求、签名、证书加载等细节,代码量能减少一大半。原生实现的好处是逻辑透明,出问题时容易定位,适合学习阶段理解支付流程。
异步回调处理:验签与订单状态更新
回调是整个支付链路中最关键的环节,也是最容易出问题的地方。支付平台在用户付款成功后会向notify_url推送XML格式的通知,你的脚本必须完成三件事:验签、校验金额、更新订单,最后返回success表示处理成功,否则平台会按一定间隔重复推送。
验签是安全的核心,目的是确认这条通知确实来自支付平台而不是被人伪造。验签方式和下单签名一致,把通知里除sign以外的字段按ASCII排序拼接,加上密钥做MD5后与sign对比。金额校验同样不能省略,要拿通知里的total_fee与本地订单的应付金额比对,防止有人篡改金额小额支付大额订单。
<?php
// notify.php 异步回调处理
$config = require 'config/pay.php';
$xml = file_get_contents('php://input');
$data = XmlToArray($xml);
// 第一步:验证签名
$sign = $data['sign'];
unset($data['sign']);
ksort($data);
$str = '';
foreach ($data as $k => $v) {
if ($v !== '' && $k !== 'sign') {
$str .= $k . '=' . $v . '&';
}
}
$str .= 'key=' . $config['wechat']['api_key'];
if (strtoupper(md5($str)) !== $sign) {
echo 'fail'; // 验签失败直接拒绝
exit;
}
// 第二步:校验业务状态和金额
if ($data['result_code'] !== 'SUCCESS') {
echo 'fail';
exit;
}
// 从本地数据库查询订单并核对金额
$order = Db::findOrderByNo($data['out_trade_no']);
if (!$order || $order['amount'] * 100 != $data['total_fee']) {
echo 'fail';
exit;
}
// 第三步:更新订单状态,注意幂等处理
if ($order['status'] == 0) {
Db::updateOrder($order['id'], ['status' => 1, 'transaction_id' => $data['transaction_id']]);
}
// 返回成功应答,停止后续重试
echo '<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>';
?>这里要特别强调幂等性。由于平台会重复推送通知,回调脚本可能被多次触发,如果每次都执行发货逻辑就会重复发货。处理办法是先判断订单当前状态,只有未支付的订单才更新状态并触发后续业务,已支付的直接返回success即可。数据库更新最好使用带条件的update语句而不是先查后改,避免并发场景下的竞态问题。
常见问题排查与注意事项
开发过程中高频出现的问题主要有三类。第一类是签名错误,通常是密钥不对、参数值为空也参与了拼接、或者字符编码不是UTF-8导致的,可以打印拼接后的字符串逐项排查。第二类是回调收不到通知,先确认notify_url是外网可访问的https地址,本地开发需要用内网穿透工具把请求转发到本地环境。第三类是二维码扫了没反应,多半是测试环境参数和生产环境混用了,或者商户号没有开通对应的支付产品权限。
另外有几点安全实践值得注意:支付相关的接口不要相信任何前端传来的金额,金额一律以服务端数据库记录为准;回调处理不要做耗时操作,发货、发短信等业务可以丢到消息队列异步执行;日志要完整记录每次请求和响应,出现掉单时才能通过对账接口补单。上线前建议先用一分钱做全流程回归,确认支付、回调、退款链路都通畅后再对外开放。