1. 环境准备与核心概念扫盲

大家好,我是老张,一个在PHP和ThinkPHP生态里摸爬滚打了十多年的老码农。今天咱们不聊虚的,直接上手,用ThinkPHP6把微信小程序支付这个“硬骨头”给啃下来。我见过太多新手朋友,一看到支付、签名、证书这些词就头大,其实只要你跟着我的步骤走,避开我踩过的那些坑,你会发现集成支付功能并没有想象中那么复杂。

在开始敲代码之前,我们必须把几个核心的“家当”准备好。这就像你要开个餐馆,得先有店面、有营业执照、有厨具一样。第一,你需要一个微信小程序,并且已经完成了企业认证,因为个人主体的小程序是不支持支付功能的。第二,你需要申请微信支付商户号,这个在微信支付商户平台申请,申请下来后你会得到几个关键信息:商户号(MCHID)、小程序AppID、还有最重要的API证书。第三,你得有一个已经搭建好的ThinkPHP6项目,并且能正常运行。

这里我重点说一下API证书,这是最容易出问题的地方。在微信支付V3版API中,我们不再使用旧的API密钥(Key)进行简单的MD5签名,而是改用更安全的RSA公私钥对。你需要登录商户平台,在“账户中心”->“API安全”里下载证书。你会得到一个压缩包,里面包含apiclient_cert.pem(公钥证书,其实V3接口签名用不上它)、apiclient_key.pem(私钥,签名的核心!)以及一个微信支付平台证书。我们后端签名主要用的就是那个apiclient_key.pem文件。记得把这个文件妥善保存在服务器上,比如我习惯放在项目的/cert目录下,并且一定要设置好文件权限,防止被外部读取。

另一个关键概念是**“平台证书”**。这是微信支付平台的公钥证书,用于验证微信回调通知的签名,确保通知真的是微信服务器发来的,而不是黑客伪造的。这个证书不是固定不变的,微信会定期更新,所以我们需要在代码里实现一个“下载并缓存”的逻辑,不能写死。很多人在回调验证这里栽跟头,就是因为忽略了平台证书的动态性。别担心,后面我会给出具体的实现代码。

2. 构建坚如磐石的平台订单系统

支付流程的起点,一定是咱们自己平台的订单系统。这一步看似和微信支付无关,但却是整个流程稳定性的基石。如果平台订单创建得马马虎虎,后面支付回调来了,你连对账都困难。我的经验是,订单表的设计要考虑到各种状态:待支付、支付中、已支付、已取消、已退款等等。同时,一定要生成一个全局唯一的平台订单号(out_trade_no),我推荐使用“时间戳+随机数+用户ID片段”的组合,或者直接使用雪花算法生成,确保唯一性。

在ThinkPHP6里,我们通常会在一个控制器的方法里处理创建订单和发起微信支付的逻辑。这里事务(Transaction) 的使用至关重要。想象一下这个场景:用户点击支付,我们创建了平台订单记录,然后去调用微信下单接口,结果微信那边因为网络问题返回失败了。这个时候,如果我们不把之前创建的平台订单回滚掉,用户就会看到一个“未支付”的订单躺在列表里,但实际上支付流程根本没发起成功,这就会造成数据混乱。所以,我们必须把创建平台订单和调用微信支付API放在同一个数据库事务里。

我来演示一个更健壮的订单创建代码片段。假设我们有一个Order模型和一个order数据表。

use app\model\Order;
use think\facade\Db;

public function createOrder()
{
    // 获取前端传来的商品信息、用户ID等
    $userId = request()->userId;
    $goodsId = request()->goodsId;
    $totalFee = request()->totalFee; // 单位:元

    // 开启事务
    Db::startTrans();
    try {
        // 1. 生成唯一的平台订单号
        $outTradeNo = date('YmdHis') . substr(microtime(), 2, 6) . sprintf('%04d', $userId % 10000);

        // 2. 创建平台订单记录
        $orderData = [
            'out_trade_no' => $outTradeNo,
            'user_id'      => $userId,
            'goods_id'     => $goodsId,
            'total_fee'    => $totalFee * 100, // 转为分,存入数据库
            'status'       => 0, // 0-待支付
            'create_time'  => time(),
        ];
        $order = Order::create($orderData);
        if (!$order) {
            throw new \Exception('平台订单创建失败');
        }

        // 3. 调用微信支付统一下单接口(这一步稍后详细展开)
        $wxPayResult = $this->createWxPayOrder($outTradeNo, $totalFee, $userId);
        // 如果微信下单失败,会抛出异常,被下面的catch捕获

        // 4. 更新平台订单的预支付信息(如 prepay_id,可选)
        $order->prepay_id = $wxPayResult['prepay_id'];
        $order->save();

        // 所有操作成功,提交事务
        Db::commit();

        // 5. 将微信支付所需的参数返回给小程序端
        return json([
            'code' => 1,
            'msg'  => '成功',
            'data' => $wxPayResult['payment_params'] // 包含 timeStamp, nonceStr, package, signType, paySign
        ]);

    } catch (\Exception $e) {
        // 任何一步出错,回滚事务
        Db::rollback();
        // 记录日志,非常重要!
        Log::error('支付订单创建失败:' . $e->getMessage() . ',订单号:' . ($outTradeNo ?? ''));
        return json(['code' => 0, 'msg' => '支付发起失败:' . $e->getMessage()]);
    }
}

这段代码的核心思想是“原子性”:要么所有步骤(创建本地订单、微信下单)全部成功,要么全部失败回滚,数据库里不会留下一个孤立的“待支付”订单。try...catch和事务的结合,是保证业务逻辑严谨性的标准做法。

3. 微信支付V3接口调用与签名详解

好了,平台订单在事务的保护下创建好了,接下来就是重头戏:调用微信支付V3的JSAPI下单接口。V3接口和之前的V2接口在签名和安全上有很大不同,这也是很多开发者觉得“坑”最多的地方。别怕,我们一步步拆解。

首先,你需要构造一个符合微信要求的请求体。这个请求体是一个JSON格式的数据。

private function createWxPayOrder($outTradeNo, $totalFee, $userId)
{
    // 1. 组装请求数据
    $postData = [
        'appid'        => config('wxpay.appid'), // 小程序AppID
        'mchid'        => config('wxpay.mchid'), // 商户号
        'description'  => '商品描述-测试', // 商品简单描述
        'out_trade_no' => $outTradeNo, // 你的平台订单号
        'notify_url'   => request()->domain() . '/api/payment/notify', // 支付结果回调地址,必须是HTTPS
        'amount'       => [
            'total'    => intval($totalFee * 100), // 总金额,单位是分!这里是个大坑,一定要转成整数。
            'currency' => 'CNY'
        ],
        'payer'        => [
            'openid' => $this->getUserOpenId($userId) // 获取当前小程序用户的openid
        ]
    ];
    // 注意:`notify_url`是公网可访问的HTTPS地址,用于接收异步支付结果。本地开发可以用内网穿透工具(如ngrok)来调试。

接下来是V3接口的核心难点:签名。V3接口使用WECHATPAY2-SHA256-RSA2048签名方案,签名不是对请求体简单加密,而是对一个特定的签名串进行加密。这个签名串的格式有严格规定:

请求方法\n
URL(不含域名)\n
时间戳\n
随机字符串\n
请求体(JSON字符串)\n

是的,你没看错,最后有一个换行符\n。少一个换行符,签名就会失败。我们来写这个签名方法:

private function getAuthorizationHeader($method, $url, $body)
{
    $timestamp = time();
    $nonceStr = $this->generateNonceStr(); // 生成随机字符串,比如用uniqid()
    $urlPath = parse_url($url, PHP_URL_PATH); // 提取URL路径,如 `/v3/pay/transactions/jsapi`

    // 1. 构建签名字符串
    $signMessage = $method . "\n"
                 . $urlPath . "\n"
                 . $timestamp . "\n"
                 . $nonceStr . "\n"
                 . $body . "\n"; // 注意最后的换行

    // 2. 加载商户私钥
    $privateKeyPath = app()->getRootPath() . 'cert/apiclient_key.pem';
    $privateKey = file_get_contents($privateKeyPath);
    if (!$privateKey) {
        throw new \Exception('商户私钥文件读取失败');
    }

    // 3. 使用SHA256 with RSA进行签名
    openssl_sign($signMessage, $signature, $privateKey, OPENSSL_ALGO_SHA256);
    $signatureBase64 = base64_encode($signature);

    // 4. 构造Authorization头
    $serialNo = config('wxpay.serial_no'); // 商户API证书序列号,在证书压缩包里的txt文件里找
    $token = sprintf(
        'mchid="%s",serial_no="%s",nonce_str="%s",timestamp="%d",signature="%s"',
        config('wxpay.mchid'),
        $serialNo,
        $nonceStr,
        $timestamp,
        $signatureBase64
    );

    return [
        'Authorization: WECHATPAY2-SHA256-RSA2048 ' . $token,
        'Content-Type: application/json',
        'Accept: application/json',
        'User-Agent: YourAppName/1.0 (ThinkPHP6)', // 建议加上,方便排查
    ];
}

签名头构造好后,我们就可以发起HTTP请求了。ThinkPHP6推荐使用内置的think\facade\Http类,它更简洁。

use think\facade\Http;

private function createWxPayOrder($outTradeNo, $totalFee, $userId)
{
    // ... 构造 $postData ...
    $url = 'https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi';
    $body = json_encode($postData, JSON_UNESCAPED_UNICODE);

    // 获取签名头
    $headers = $this->getAuthorizationHeader('POST', $url, $body);

    // 发起请求
    $response = Http::withHeaders($headers)
                    ->timeout(10) // 设置超时
                    ->body($body)
                    ->post($url);

    $result = $response->getBody();
    $data = json_decode($result, true);

    if (!$response->successful() || !isset($data['prepay_id'])) {
        // 记录详细的错误信息,微信返回的HTTP状态码和body对排查非常有用
        Log::error('微信支付下单失败:' . $result);
        throw new \Exception('微信支付下单失败:' . ($data['message'] ?? '未知错误'));
    }

    // 下单成功,返回 prepay_id
    return ['prepay_id' => $data['prepay_id']];
}

注意:这里有个超级大坑!微信V3接口返回的成功HTTP状态码是200204,但真正的业务成功与否要看返回的JSON里是否有prepay_id。同时,错误信息现在非常规范,会包含codemessagedetail,一定要把这些信息记录到日志里,方便排查。

4. 生成小程序调起支付参数

拿到prepay_id之后,我们的后端工作还没完。需要生成一整套参数,返回给小程序前端,让前端能调起微信支付界面。这套参数也需要签名,但签名方式和上一步的API签名完全不同!很多开发者在这里混淆,导致前端一直报“签名错误”。

小程序端调起支付wx.requestPayment需要的参数是:

  • timeStamp:时间戳(字符串)
  • nonceStr:随机字符串
  • package:格式为 prepay_id=xxx
  • signType:签名类型,固定为 RSA
  • paySign:支付签名

这里的paySign签名,是对下面这个字符串进行SHA256 with RSA签名:

小程序appId\n
时间戳\n
随机字符串\n
prepay_id=xxx\n

注意,这里签名的内容不包含signType,并且字符串之间用换行符\n连接。我们来写这个生成方法:

private function getMiniProgramPaymentParams($prepayId)
{
    $appId = config('wxpay.appid');
    $timeStamp = (string)time(); // 必须转为字符串
    $nonceStr = $this->generateNonceStr();
    $package = 'prepay_id=' . $prepayId;

    // 构建签名字符串
    $signMessage = $appId . "\n"
                 . $timeStamp . "\n"
                 . $nonceStr . "\n"
                 . $package . "\n";

    // 加载同一个商户私钥进行签名
    $privateKeyPath = app()->getRootPath() . 'cert/apiclient_key.pem';
    $privateKey = file_get_contents($privateKeyPath);
    openssl_sign($signMessage, $signature, $privateKey, OPENSSL_ALGO_SHA256);
    $paySign = base64_encode($signature);

    return [
        'timeStamp' => $timeStamp,
        'nonceStr'  => $nonceStr,
        'package'   => $package,
        'signType'  => 'RSA', // 固定值
        'paySign'   => $paySign,
    ];
}

这样,在createOrder方法里,当微信下单成功后,我们就可以调用getMiniProgramPaymentParams方法生成参数,并返回给前端了。前端拿到这五个参数,就可以调用wx.requestPayment了。

5. 处理支付结果回调通知

用户支付成功或失败后,微信支付服务器会异步通知我们的服务端。这个回调通知(Notify) 是最终确定订单状态的依据,必须可靠、安全、幂等。所谓幂等,就是无论微信因为什么原因(比如网络超时)重复发送同一条通知,我们的处理结果都应该是一样的,不能因为重复通知而导致用户被扣款多次或订单状态被错误更新。

首先,微信会向我们在下单时传入的notify_url发送一个POST请求,请求体是JSON格式,并且带有一系列用于验证的HTTP头,最重要的是Wechatpay-SignatureWechatpay-TimestampWechatpay-NonceWechatpay-Serial。我们的任务就是验证这个签名,确保通知来自微信。

验证步骤比生成签名稍复杂一些,因为需要用到我们之前提到的“微信支付平台证书”。流程是:

  1. 从请求头中获取签名、时间戳、随机串和证书序列号。
  2. 根据证书序列号,去查找或下载对应的微信支付平台证书(公钥)。
  3. 按照微信规定的格式,拼接出待验证的签名字符串。
  4. 使用平台证书的公钥,对签名进行验证。

由于平台证书会更新,我们需要一个机制来管理它。一个简单的做法是,将下载的证书序列号和内容(PEM格式)缓存到文件或Redis中,每次验证时先根据序列号找缓存,找不到再去微信接口下载。这里我给出一个简化版的验证核心代码:

public function notify()
{
    // 1. 获取通知的原始数据(JSON字符串)和头部信息
    $rawBody = file_get_contents('php://input');
    $headers = getallheaders(); // 注意函数兼容性,ThinkPHP6可用 request()->header()

    $wechatpaySignature = $headers['Wechatpay-Signature'] ?? '';
    $wechatpayTimestamp = $headers['Wechatpay-Timestamp'] ?? '';
    $wechatpayNonce = $headers['Wechatpay-Nonce'] ?? '';
    $wechatpaySerial = $headers['Wechatpay-Serial'] ?? '';

    if (empty($wechatpaySignature)) {
        // 记录日志,返回失败
        Log::error('回调通知签名头缺失');
        $this->responseWechat('FAIL', '签名验证失败');
        return;
    }

    // 2. 构建验签串(与生成签名时顺序一致)
    $signMessage = $wechatpayTimestamp . "\n"
                 . $wechatpayNonce . "\n"
                 . $rawBody . "\n";

    // 3. 根据证书序列号,获取缓存的微信平台证书公钥
    $platformPublicKey = $this->getPlatformCertificate($wechatpaySerial);
    if (!$platformPublicKey) {
        Log::error('未找到对应的微信支付平台证书,序列号:' . $wechatpaySerial);
        $this->responseWechat('FAIL', '证书错误');
        return;
    }

    // 4. 进行验签
    $signature = base64_decode($wechatpaySignature);
    $verifyResult = openssl_verify($signMessage, $signature, $platformPublicKey, OPENSSL_ALGO_SHA256);

    if ($verifyResult !== 1) {
        Log::error('回调通知签名验证失败。通知内容:' . $rawBody);
        $this->responseWechat('FAIL', '签名验证失败');
        return;
    }

    // 5. 签名验证通过,解析通知内容
    $notifyData = json_decode($rawBody, true);
    $resource = $notifyData['resource'];
    // resource里的数据是加密的,需要用商户私钥解密
    $ciphertext = $resource['ciphertext'];
    $associatedData = $resource['associated_data'];
    $nonce = $resource['nonce'];

    // 使用AES-256-GCM解密(需要OpenSSL扩展支持)
    $decryptData = $this->decryptAesGcm($ciphertext, $associatedData, $nonce);
    $orderResult = json_decode($decryptData, true);

    // 6. 处理业务逻辑:更新订单状态
    $outTradeNo = $orderResult['out_trade_no'];
    $tradeState = $orderResult['trade_state']; // SUCCESS, REFUND, CLOSED等

    Db::startTrans();
    try {
        $order = Order::where('out_trade_no', $outTradeNo)->lock(true)->find(); // 加锁防止并发
        if (!$order) {
            throw new \Exception('订单不存在:' . $outTradeNo);
        }
        // 判断订单是否已处理过,实现幂等
        if ($order->status == 1) { // 假设1是已支付
            Log::info('订单已处理,忽略重复通知:' . $outTradeNo);
            Db::commit();
            $this->responseWechat('SUCCESS', 'OK');
            return;
        }

        if ($tradeState == 'SUCCESS') {
            $order->status = 1;
            $order->transaction_id = $orderResult['transaction_id'];
            $order->pay_time = date('Y-m-d H:i:s', strtotime($orderResult['success_time']));
            $order->save();
            // 这里可以触发后续业务,如发放商品、发送消息等
        } else {
            // 处理支付失败或关闭的情况
            $order->status = 2; // 支付失败
            $order->save();
        }
        Db::commit();
        // 7. 处理成功,返回成功响应给微信
        $this->responseWechat('SUCCESS', 'OK');
    } catch (\Exception $e) {
        Db::rollback();
        Log::error('处理支付回调失败:' . $e->getMessage() . ',订单号:' . $outTradeNo);
        $this->responseWechat('FAIL', '处理失败');
    }
}

private function responseWechat($code, $message)
{
    $response = ['code' => $code, 'message' => $message];
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($response);
    // 注意:不要用ThinkPHP的json()助手函数,它可能附加其他头信息。直接echo并结束。
    fastcgi_finish_request(); // 如果使用FPM,可以立即结束响应,继续处理后续逻辑
}

重要提示:回调处理函数中,在验签和解密成功后,更新订单状态前,务必先检查该订单是否已被处理过(通过订单状态字段判断)。这是实现幂等性的关键,能有效防止重复发货、重复增加积分等问题。另外,响应给微信的必须是SUCCESSFAIL的JSON,且HTTP状态码为200,否则微信会认为通知失败,并在之后一段时间内重试。

6. 小程序端调起支付与状态同步

后端把一切都准备好了,现在轮到小程序前端上场了。前端的工作相对简单,但细节决定成败。首先,你需要在小程序端,通过wx.login和后续接口,获取到用户的openid,并传给后端创建订单。然后,调用我们写好的创建订单接口,拿到上一节生成的那五个参数。

// pages/pay/index.js
Page({
  data: {},
  onLoad() {},
  async handlePay() {
    // 1. 先获取用户的openid(这里假设已通过登录流程获取并存储在全局或本地)
    const openid = getApp().globalData.openid;

    // 2. 调用后端创建订单接口
    wx.showLoading({ title: '创建订单中...' });
    try {
      const res = await wx.request({
        url: 'https://你的域名.com/api/order/create',
        method: 'POST',
        data: { goodsId: 123, totalFee: 0.01, openid: openid }, // 传参
        header: { 'content-type': 'application/json' }
      });
      wx.hideLoading();

      if (res.data.code !== 1) {
        wx.showToast({ icon: 'none', title: res.data.msg || '创建订单失败' });
        return;
      }

      const paymentParams = res.data.data; // 拿到 timeStamp, nonceStr, package, signType, paySign

      // 3. 调起微信支付
      wx.requestPayment({
        ...paymentParams, // 展开参数
        success: (res) => {
          // 注意:success仅代表支付界面调用成功,不代表支付成功。支付结果以后端异步回调为准。
          if (res.errMsg === 'requestPayment:ok') {
            wx.showModal({
              title: '提示',
              content: '支付请求已发送,请等待支付结果确认。',
              showCancel: false,
              success: (modalRes) => {
                // 跳转到订单结果页,并开始轮询查询订单状态
                wx.redirectTo({ url: `/pages/order/result?outTradeNo=${你之前生成的订单号}` });
              }
            });
          }
        },
        fail: (err) => {
          console.error('支付调起失败', err);
          // 失败原因有很多:用户取消、网络问题、参数错误等
          if (err.errMsg === 'requestPayment:fail cancel') {
            wx.showToast({ icon: 'none', title: '支付已取消' });
          } else {
            wx.showToast({ icon: 'none', title: '支付失败:' + err.errMsg });
          }
        }
      });
    } catch (error) {
      wx.hideLoading();
      wx.showToast({ icon: 'none', title: '网络请求异常' });
    }
  }
})

这里有个非常重要的点:wx.requestPaymentsuccess回调,只表示支付界面被成功调起并完成了操作(用户输入密码或指纹),并不代表支付已经成功到账。最终的支付结果,必须以我们后端收到的异步回调通知为准。

因此,最佳实践是:在success回调里,不要直接告诉用户“支付成功”,而是提示“支付请求已提交”,然后跳转到一个订单结果页面。在这个结果页面,启动一个定时器,每隔几秒向后端查询一下该订单的最终状态(通过我们平台订单的status字段),直到查询到明确成功或失败的结果,再给用户展示。这样就实现了前后端状态的最终一致性。

7. 实战避坑与最佳实践总结

走完了全流程,我们来集中盘点一下那些最容易让人“掉进去”的坑,以及我总结出来的一些最佳实践。

第一大坑:金额单位。 微信支付接口里,所有金额单位都是。你在数据库里存“元”,在计算时转“分”,非常容易忘记乘以100,或者忘记转成整数。我建议在数据库里就直接存储以“分”为单位的整数金额,一劳永逸,避免浮点数计算带来的精度问题。

第二大坑:签名错误。 这是反馈最多的问题。请严格按照以下清单自查:

  1. 签名串格式:是否严格按照方法\n路径\n时间戳\n随机串\n请求体\n的格式拼接?最后的换行符\n有没有漏掉?
  2. 公私钥对应:API签名和生成小程序支付参数paySign,使用的是商户私钥apiclient_key.pem)。验证回调通知签名,使用的是微信支付平台证书公钥。千万别用错。
  3. 证书序列号Authorization头里的serial_no,填的是商户API证书的序列号,不是平台证书的。这个序列号在下载证书的压缩包里的txt文件中。
  4. 时间戳和随机串:生成签名和验证签名时使用的timestampnonce必须完全一致,并且要注意服务器时间是否准确。

第三大坑:回调通知处理。 一定要做好幂等验签。我见过有开发者因为没验签,被恶意伪造的回调通知刷了库存。验签通过后,先根据out_trade_no查询订单,如果订单已是终态(如已支付),直接返回SUCCESS,不要再做任何更新操作。

第四大坑:网络与超时。 调用微信支付API和接收回调,都要考虑网络不稳定。下单接口要设置合理的超时时间(比如10秒),并做好异常捕获和重试机制(但要注意,创建订单这类非幂等操作重试要谨慎)。回调接口处理业务逻辑要快,如果业务复杂(比如发短信、更新多个表),可以考虑在验证签名并解密数据后,将核心数据推送到消息队列,然后立即返回SUCCESS给微信,再由队列的消费者异步处理,避免微信因超时而重试。

关于ThinkPHP6的配置,我建议将微信支付的配置项(AppID、MCHID、API密钥路径、证书序列号等)放在config目录下的一个独立配置文件里,比如wxpay.php,然后通过config(‘wxpay.appid’)来读取。这样管理和切换环境(开发、测试、生产)会方便很多。

最后,支付功能上线前,务必在沙箱环境或使用真实小额支付进行充分测试。测试要覆盖整个流程:正常支付、用户取消、支付失败、网络中断、重复回调等。日志记录要详尽,把关键步骤、请求参数、响应数据都记录下来,这样一旦线上出问题,你才能快速定位。支付无小事,每一个环节的严谨,都是对用户和业务的负责。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐