PHP如何对接微信支付并处理异步回调与退款逻辑流程

大家好,我是你们的“搞钱”专家。

今天咱们不聊那些虚头巴脑的架构设计,咱们来点带“油水”的——微信支付对接

说到微信支付,很多PHP程序员的心态是这样的:“一入支付深似海,从此签名是路人。”

微信支付就像是一个难伺候的土豪房东,你每次想从他口袋里掏钱,或者往他口袋里送钱,都得按他的规矩来。你要是不按规矩办事,他一秒钟能让你把代码跑断,甚至让你怀疑人生。

今天,我们就来扒开微信支付的马甲,用PHP这块老姜,把它煮得透透的。咱们不讲废话,直接上干货,重点攻克统一下单异步回调退款逻辑这三个核心关卡。


第一关:起手式——准备好你的“身份证”

在咱们敲代码之前,得先把自己打扮得像个正经人。微信支付不跟小孩子玩游戏,它需要你提供三样东西:

  1. 商户号:这是你在微信那边的工号。
  2. AppID:你的应用ID,证明你是谁。
  3. 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 接口。

退款逻辑比支付简单,但也有几个坑:

  1. 全额退款:可以用商户单号(out_trade_no)。
  2. 部分退款:可以用商户单号,也可以用微信支付单号(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_SSLCERTCURLOPT_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(新版)
这是微信未来的方向。它变了啥?

  1. JSON 为主:不再跟 XML 耗着了,发 JSON。
  2. 签名变了:不再用简单的 MD5,改用 非对称加密(RSA2)。你需要配置 RSA 的公钥和私钥。
  3. 证书变了:不再需要那个 pem 文件了,改用 证书序列号
  4. 服务端 SDK:微信官方提供了 PHP SDK,虽然封装了,但有时候反而不如自己手写灵活。

怎么选?
如果你的项目是几年前写的,还在用 V2,别折腾了,能跑就行。
如果你是新项目,或者你想拥抱未来,强烈建议用 V3。虽然上手有门槛,但安全性高得多,体验也好得多。

简单看一眼 V3 的签名逻辑(对比一下):
V2 是 MD5(key)
V3 是 SHA256_WITH_RSA
你需要用私钥对参数进行排序加密。


总结一下流程图

咱们把今天讲的串一下,就像讲故事一样:

  1. 用户下单 -> 你生成一个唯一的 out_trade_no
  2. 调起支付 -> 你用这个单号去微信换 prepay_id
  3. 用户付款 -> 微信把“我付了”的消息发到你写的回调接口。
  4. 验证回调 -> 你校验签名、校验金额、查数据库,然后发货。
  5. 用户退款 -> 你拿着单号去微信要回钱。

这就是整个闭环。

最后送大家一句话:处理微信支付,心态要稳,验证要严,证书要备好。

如果遇到报错,先看返回码,别急着改代码。微信的错误码通常都写得明明白白,ILLEGAL_KEY 是你密钥错了,SIGNERROR 是你签名错了,PARAM_ERROR 是你参数名错了。

好了,今天的讲座就到这里。赶紧去把你的代码跑通吧,毕竟,赚到了钱,才是硬道理!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注