大家好,我是你们的“搞钱”专家。
今天咱们不聊那些虚头巴脑的架构设计,咱们来点带“油水”的——微信支付对接。
说到微信支付,很多PHP程序员的心态是这样的:“一入支付深似海,从此签名是路人。”
微信支付就像是一个难伺候的土豪房东,你每次想从他口袋里掏钱,或者往他口袋里送钱,都得按他的规矩来。你要是不按规矩办事,他一秒钟能让你把代码跑断,甚至让你怀疑人生。
今天,我们就来扒开微信支付的马甲,用PHP这块老姜,把它煮得透透的。咱们不讲废话,直接上干货,重点攻克统一下单、异步回调、退款逻辑这三个核心关卡。
第一关:起手式——准备好你的“身份证”
在咱们敲代码之前,得先把自己打扮得像个正经人。微信支付不跟小孩子玩游戏,它需要你提供三样东西:
- 商户号:这是你在微信那边的工号。
- AppID:你的应用ID,证明你是谁。
- API Key (密钥):这是你的私钥。注意! 这玩意儿千万别放在代码里,也别放在前端。你得藏在服务器里,或者用环境变量。谁要是把它写在代码里被扫描了,别说4000字文章,你这辈子的钱都付不起赔偿金。
咱们先搭个架子。这里为了演示方便,我写了一个 WxPayService 类。别嫌我啰嗦,代码写得细致,上线时你才能少掉几根头发。
<?php
/**
* 微信支付服务类
* 这是一个基于微信支付API V2版本的经典实现
*/
class WxPayService {
private $config = [
'mch_id' => '你的商户号', // 商户号,你的工号
'appid' => '你的AppID', // 你的AppID
'key' => '你的32位API密钥', // 搞好保密工作!
'cert_path' => './cert/apiclient_cert.pem', // 商户证书路径
'key_path' => './cert/apiclient_key.pem', // 商户私钥路径
'ssl_cer' => './cert/rootca.pem', // 证书链,有的环境必须
'notify_url' => 'https://你的域名.com/api/pay/notify' // 异步回调地址,必须是公网能访问的
];
/**
* 生成签名
* 这是微信支付最核心的环节,就像你在合同上签字按手印一样
*/
public function makeSign($params) {
// 1. 检查必填参数
// 2. 对参数排序 (字典序)
ksort($params);
// 3. 拼接字符串
$string = '';
foreach ($params as $k => $v) {
if ($v === '' || $k === 'sign') continue;
$string .= $k . '=' . $v . '&';
}
$string = rtrim($string, '&');
// 4. 拼接Key
$string .= '&key=' . $this->config['key'];
// 5. MD5加密
$string = md5($string);
// 6. 转大写
return strtoupper($string);
}
/**
* 发起HTTP请求
* 微信接口很少用原生fsockopen,用CURL是标配
*/
private function httpRequest($url, $data) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // 开发环境关闭验证,生产环境记得开启
curl_setopt($ch, CURLOPT_SSLCERTTYPE, 'PEM');
curl_setopt($ch, CURLOPT_SSLCERT, $this->config['cert_path']);
curl_setopt($ch, CURLOPT_SSLKEYTYPE, 'PEM');
curl_setopt($ch, CURLOPT_SSLKEY, $this->config['key_path']);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
$output = curl_exec($ch);
curl_close($ch);
return $output;
}
}
第二关:统一下单——把钱递过去
用户在前端点击“支付”按钮,后台需要向微信发个请求:“喂,给我开张单子!”
这就是 unifiedorder。这里有个大坑:签名必须放在最后。很多新手喜欢把签名算好扔数组里,结果微信说你没签名,因为你漏了最后一步拼接。
咱们来写这个逻辑:
/**
* 统一下单逻辑
* @param array $orderData 订单数据 ['out_trade_no'=>'20231001', 'total_fee'=>100, 'body'=>'测试商品']
*/
public function unifiedOrder($orderData) {
// 微信支付参数转换:金额需要转成“分”,不是“元”
// 这是很多新手最容易搞错的地方,差一分钱都付不了
$amount = $orderData['total_fee'] * 100;
// 构建请求数据
$params = [
'appid' => $this->config['appid'],
'mch_id' => $this->config['mch_id'],
'nonce_str' => $this->getNonceStr(), // 随机字符串,防重复
'body' => $orderData['body'],
'out_trade_no' => $orderData['out_trade_no'], // 商户订单号,系统唯一
'total_fee' => $amount,
'spbill_create_ip' => $_SERVER['REMOTE_ADDR'],
'notify_url' => $this->config['notify_url'],
'trade_type' => 'JSAPI', // 支付类型,JSAPI是公众号支付,NATIVE是扫码支付,APP是APP支付
'openid' => $orderData['openid'] // 如果是公众号支付,必须传用户openid
];
// 生成签名
$params['sign'] = $this->makeSign($params);
// 请求微信接口
$url = "https://api.mch.weixin.qq.com/pay/unifiedorder";
$xmlData = $this->arrayToXml($params);
$responseXml = $this->httpRequest($url, $xmlData);
// 解析返回结果
$result = $this->xmlToArray($responseXml);
if ($result['return_code'] == 'SUCCESS' && $result['result_code'] == 'SUCCESS') {
// 订单生成成功,给前端返回prepay_id
// 前端拿到这个prepay_id才能调起支付组件
return [
'code' => 200,
'msg' => '下单成功',
'data' => [
'appId' => $this->config['appid'],
'timeStamp' => time(),
'nonceStr' => $params['nonce_str'],
'package' => 'prepay_id=' . $result['prepay_id'],
'signType' => 'MD5'
]
];
} else {
return [
'code' => 400,
'msg' => $result['return_msg'] . ' ' . $result['err_code_des']
];
}
}
// 辅助:生成随机字符串
private function getNonceStr($length = 32) {
$chars = "abcdefghijklmnopqrstuvwxyz0123456789";
$str = "";
for ($i = 0; $i < $length; $i++) {
$str .= substr($chars, mt_rand(0, strlen($chars) - 1), 1);
}
return $str;
}
// 辅助:数组转XML(微信接口只认XML)
private function arrayToXml($arr) {
$xml = "<xml>";
foreach ($arr as $key => $val) {
if (is_numeric($val)) {
$xml .= "<" . $key . ">" . $val . "</" . $key . ">";
} else {
$xml .= "<" . $key . "><![CDATA[" . $val . "]]></" . $key . ">";
}
}
$xml .= "</xml>";
return $xml;
}
// 辅助:XML转数组
private function xmlToArray($xml) {
$msg = (array)simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA);
return $msg;
}
专家点评:
你看,这里有个 prepay_id。这个玩意儿非常重要,它是一个“一次有效”的凭证。前端拿到这个ID,再结合 appId、时间戳、随机串、签名(注意:这里前端还要再算一遍签名,这次是 sign 字段,和订单签名略有不同),调起微信JSAPI或者APP的支付SDK。
第三关:异步回调——防盗门来了
这是最关键的一步。用户付完款,微信的服务器会像快递员一样,把“我付过了”这个消息扔到你写的接口里。这就是 回调。
千万别相信前端传来的金额! 前端传过来的钱数,你完全可以改成“1000元”,但微信服务器那边是真实的。如果你的数据库没核对,你后台记账记了1000元,结果用户只付了1块钱,你公司就亏大了。这就是传说中的“幽灵退款”。
咱们来看看回调处理代码:
/**
* 支付回调处理入口
* 必须验证签名,必须验证金额,必须处理幂等性(防止重复处理)
*/
public function notify() {
// 1. 获取微信POST过来的XML数据
// 这里的坑在于:有些老版本的PHP配置可能无法直接解析php://input,建议用var_dump看一眼
$xml = file_get_contents("php://input");
if (empty($xml)) {
return 'NO DATA';
}
$data = $this->xmlToArray($xml);
// 2. 验证签名 (这是必须的!)
// 拿着返回的XML里的所有参数(除了sign),重新算一遍签名,看是否等于 data['sign']
$sign = $data['sign'];
unset($data['sign']); // 把sign去掉,因为sign不参与签名计算
$mySign = $this->makeSign($data); // 重新算签名
if ($sign !== $mySign) {
// 签名不对,这可能是黑客在篡改数据,或者你自己代码写崩了
error_log("微信支付回调签名校验失败: " . var_export($data, true));
return "FAIL"; // 告诉微信,我没收到有效通知
}
// 3. 验证业务状态
if ($data['return_code'] !== 'SUCCESS' || $data['result_code'] !== 'SUCCESS') {
// 交易失败或通知异常,直接返回FAIL
return "FAIL";
}
// 4. 获取订单信息
$out_trade_no = $data['out_trade_no']; // 商户订单号
$transaction_id = $data['transaction_id']; // 微信支付订单号
$total_fee = $data['total_fee']; // 支付金额(分)
// 5. 查询本地数据库,防止重复处理
// 比如你的订单已经变成“已支付”了,再收到一次回调,就别再发短信、别再发货了
$order = $this->db->query("SELECT * FROM orders WHERE order_no = ?", $out_trade_no)->fetch();
if (!$order) {
return "FAIL"; // 订单不存在
}
if ($order['status'] == 'paid') {
return "SUCCESS"; // 重复通知,直接告诉微信我已经处理了
}
// 6. 核对金额(关键!)
// 转换一下格式方便比较
$dbTotal = $order['amount'] * 100;
if ($total_fee != $dbTotal) {
// 金额不一致!这可是大事!
// 方案A:直接记录日志,报警,人工介入。
// 方案B:把这笔钱退了(退款逻辑在后面)。
error_log("金额不符!微信支付了:{$total_fee}, 本地记录:{$dbTotal}");
return "FAIL";
}
// 7. 更新本地数据库
// 开启数据库事务,保证原子性
$this->db->beginTransaction();
try {
// 更新订单状态
$this->db->exec("UPDATE orders SET status='paid', pay_time=NOW(), transaction_id=? WHERE order_no=?", [$transaction_id, $out_trade_no]);
// 扣减库存
$this->db->exec("UPDATE products SET stock = stock - 1 WHERE id = ?", [$order['product_id']]);
// 发送短信通知用户“支付成功”
// SmsService::send($order['phone'], "恭喜您,购买成功!");
$this->db->commit();
// 返回SUCCESS告诉微信,我处理完了
return "SUCCESS";
} catch (Exception $e) {
$this->db->rollBack();
error_log("支付回调处理异常: " . $e->getMessage());
return "FAIL";
}
}
专家点评:
看到 file_get_contents("php://input") 了吗?这是处理微信XML回调的独门秘籍。还有那个 if ($order['status'] == 'paid'),这就是幂等性处理。微信服务器有时候喜欢“婆婆妈妈”,同一个回调可能发两次,你处理一次,再来一次,你再判断一下状态,别懵了。
第四关:退款逻辑——把钱吐出来
用户说:“哎呀,我手滑了,我不想要了。” 这时候,你需要调用 refund 接口。
退款逻辑比支付简单,但也有几个坑:
- 全额退款:可以用商户单号(
out_trade_no)。 - 部分退款:可以用商户单号,也可以用微信支付单号(
transaction_id)。部分退款必须传refund_fee(退款金额)和total_fee(原订单金额)。
注意:退款通常需要原路退回,除非你开了其他的退款渠道权限。
/**
* 退款逻辑
* @param string $out_trade_no 商户订单号
* @param float $refundAmount 退款金额(元)
*/
public function refund($out_trade_no, $refundAmount) {
// 1. 参数准备
// 退款金额必须是整数分
$refundFee = intval($refundAmount * 100);
// 查一下原订单总金额,防止退多了
$order = $this->db->query("SELECT total_fee FROM orders WHERE order_no = ?", $out_trade_no)->fetch();
if (!$order) return ['code' => 404, 'msg' => '订单不存在'];
$totalFee = $order['total_fee'];
if ($refundFee > $totalFee) {
return ['code' => 400, 'msg' => '退款金额大于订单金额'];
}
$params = [
'appid' => $this->config['appid'],
'mch_id' => $this->config['mch_id'],
'nonce_str' => $this->getNonceStr(),
'out_trade_no' => $out_trade_no,
'out_refund_no' => $out_trade_no . '_REFUND', // 退款单号,建议加上后缀防重复
'total_fee' => $totalFee,
'refund_fee' => $refundFee,
'op_user_id' => $this->config['mch_id'] // 操作员账号,默认填商户号
];
$params['sign'] = $this->makeSign($params);
// 2. 请求退款接口
$url = "https://api.mch.weixin.qq.com/secapi/pay/refund";
$xmlData = $this->arrayToXml($params);
$responseXml = $this->httpRequest($url, $xmlData);
$result = $this->xmlToArray($responseXml);
if ($result['return_code'] == 'SUCCESS' && $result['result_code'] == 'SUCCESS') {
return [
'code' => 200,
'msg' => '退款成功',
'data' => [
'refund_id' => $result['refund_id'], // 微信退款单号
'refund_fee' => $result['refund_fee']
]
];
} else {
return [
'code' => 400,
'msg' => $result['return_msg'] . ' ' . $result['err_code_des']
];
}
}
专家点评:
注意那个 op_user_id。如果商家号变更了,退款操作者填错了,退款也会失败。而且,退款接口是 SECAPI(安全接口),所以那个 httpRequest 方法里的证书配置(CURLOPT_SSLCERT 和 CURLOPT_SSLKEY)是必须的。如果不用证书,微信直接给你返回“签名错误”,坑死人。
第五关:证书——隐形的枷锁
很多时候,你的签名算得再对,微信还是报错:“证书校验失败”或者“签名错误”。
证书是什么?
这是微信给你的一张“印泥”。在 PHP 里,你需要两样东西:apiclient_cert.pem(公钥)和 apiclient_key.pem(私钥)。去微信商户平台 -> 账户中心 -> API安全 -> 下载证书。
怎么用?
就在我上面的 httpRequest 函数里。你需要设置:
curl_setopt($ch, CURLOPT_SSLCERT, $this->config['cert_path']);
curl_setopt($ch, CURLOPT_SSLKEY, $this->config['key_path']);
如果你用了 HTTPS,还要配置 CA 证书,否则很多 Linux 服务器可能会报错:“SSL certificate problem: unable to get local issuer certificate”。这时候把 rootca.pem 放进去就好了。
第六关:那些年我们踩过的坑
好了,流程走通了,代码也贴了,但现实往往比代码复杂。
1. 金额是分,不是元
我说过八百遍了,还是有人把 1.00 元写成 1 元传过去。微信死活报错“金额不合法”。记住,统一在代码里转换成 * 100。
2. 签名大小写
微信的签名是大写的 MD5。有些代码库喜欢返回小写 md5,你得 strtoupper() 强制转一下。
3. 回调地址必须是外网
如果你在本地 phpstudy 测试,微信根本找不到你的接口。你需要用内网穿透工具(比如 ngrok 或 natapp),或者直接买台云服务器。别问我怎么知道的,我当年为了测试回调,差点把家里的网线拔了。
4. 重复退款
这是最致命的Bug。
场景:用户付了100元。
第一次退款:你调用了退款接口,成功。
第二次退款:你不知道,又调了一次退款接口,因为你的代码里没校验“退款次数”或者“退款状态”。
结果:微信给用户退了两笔钱。用户乐开了花,你老板却在骂娘。
对策: 每次退款前,查一下数据库,看有没有“部分退款”的记录。如果已经退了一半,剩下的部分千万别再退了。
5. 证书过期
微信的证书有效期是一年。如果你配置了证书,过了一年突然报错,别以为是你的代码写挂了,去官网下载最新的证书覆盖上去。
第七关:进阶视角——API V3 vs API V2
各位,现在的环境是 PHP 8.0、7.4 甚至 5.6 还在跑。但微信支付也升级了。
API V2(旧版):
就像咱们上面写的。全是 XML,全是签名,全是 curl。优点是老项目熟悉,缺点是繁琐,不安全。
API V3(新版):
这是微信未来的方向。它变了啥?
- JSON 为主:不再跟 XML 耗着了,发 JSON。
- 签名变了:不再用简单的 MD5,改用 非对称加密(RSA2)。你需要配置 RSA 的公钥和私钥。
- 证书变了:不再需要那个
pem文件了,改用 证书序列号。 - 服务端 SDK:微信官方提供了 PHP SDK,虽然封装了,但有时候反而不如自己手写灵活。
怎么选?
如果你的项目是几年前写的,还在用 V2,别折腾了,能跑就行。
如果你是新项目,或者你想拥抱未来,强烈建议用 V3。虽然上手有门槛,但安全性高得多,体验也好得多。
简单看一眼 V3 的签名逻辑(对比一下):
V2 是 MD5(key)。
V3 是 SHA256_WITH_RSA。
你需要用私钥对参数进行排序加密。
总结一下流程图
咱们把今天讲的串一下,就像讲故事一样:
- 用户下单 -> 你生成一个唯一的
out_trade_no。 - 调起支付 -> 你用这个单号去微信换
prepay_id。 - 用户付款 -> 微信把“我付了”的消息发到你写的回调接口。
- 验证回调 -> 你校验签名、校验金额、查数据库,然后发货。
- 用户退款 -> 你拿着单号去微信要回钱。
这就是整个闭环。
最后送大家一句话:处理微信支付,心态要稳,验证要严,证书要备好。
如果遇到报错,先看返回码,别急着改代码。微信的错误码通常都写得明明白白,ILLEGAL_KEY 是你密钥错了,SIGNERROR 是你签名错了,PARAM_ERROR 是你参数名错了。
好了,今天的讲座就到这里。赶紧去把你的代码跑通吧,毕竟,赚到了钱,才是硬道理!