Vue + Laravel 微信支付 Native 实战:从接入到上线,完整避坑指南

5 阅读5分钟

💰 Vue + Laravel 微信支付 Native 实战:从接入到上线,完整避坑指南

前后端分离架构下的微信支付完整解决方案,2026 年最新版


一、前言

在电商、SaaS、知识付费等场景中,微信支付是必不可少的功能。本文将以 Vue 3 + Laravel 10/11 为例,详细讲解如何接入微信 Native 支付(扫码支付),涵盖从申请商户号到生产上线的全流程。

为什么选择 Native 支付?

支付方式适用场景优点
Native 支付PC 网站、H5 页面扫码用户体验好,转化率高
JSAPI 支付微信公众号内无需扫码,直接支付
H5 支付手机浏览器支持外部浏览器
小程序支付微信小程序生态内闭环

二、准备工作

2.1 申请微信支付商户号

  1. 登录 微信支付商户平台
  2. 完成企业认证
  3. 获取以下关键信息:
参数说明获取位置
appid公众号/小程序 AppID微信开放平台
mchid商户号商户平台 → 账户中心
serial_no商户证书序列号商户平台 → API 安全
private_key商户私钥下载 apiclient_key.pem
wechat_cert微信平台证书接口获取或下载
api_keyAPI v3 密钥商户平台 → API 安全

2.2 安装依赖

# Laravel 后端
composer require yansongda/pay  # 推荐支付 SDK
# 或手动实现
composer require guzzlehttp/guzzle

# Vue 前端
npm install qrcode.vue  # 二维码生成
npm install axios       # HTTP 请求

2.3 配置环境变量

# .env
WX_PAY_APPID=wx8888888888888888
WX_PAY_MCHID=1234567890
WX_PAY_SERIAL_NO=商户证书序列号
WX_PAY_PRIVATE_KEY_PATH=storage/certs/apiclient_key.pem
WX_PAY_WECHAT_CERT_PATH=storage/certs/wechat_pay_cert.pem
WX_PAY_API_V3_KEY=32 位 API v3 密钥
WX_PAY_NOTIFY_URL=https://your-domain.com/api/pay/wechat/notify

三、Laravel 后端实现

3.1 创建支付服务类

<?php
// app/Services/WechatPayService.php

namespace App\Services;

use GuzzleHttp\Client;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

class WechatPayService
{
    protected $appId;
    protected $mchId;
    protected $serialNo;
    protected $privateKey;
    protected $wechatCert;
    protected $apiV3Key;
    protected $notifyUrl;
    protected $client;

    public function __construct()
    {
        $this->appId = config('wechat_pay.appid');
        $this->mchId = config('wechat_pay.mchid');
        $this->serialNo = config('wechat_pay.serial_no');
        $this->privateKey = file_get_contents(config('wechat_pay.private_key_path'));
        $this->apiV3Key = config('wechat_pay.api_v3_key');
        $this->notifyUrl = config('wechat_pay.notify_url');
        
        $this->client = new Client([
            'base_uri' => 'https://api.mch.weixin.qq.com/',
            'cert' => config('wechat_pay.wechat_cert_path'),
        ]);
    }

    /**
     * 创建 Native 支付订单
     */
    public function createNativeOrder(array $orderInfo): array
    {
        $outTradeNo = $orderInfo['out_trade_no'];
        $amount = $orderInfo['amount']; // 单位:分
        
        $payload = [
            'appid' => $this->appId,
            'mchid' => $this->mchId,
            'description' => $orderInfo['description'],
            'out_trade_no' => $outTradeNo,
            'notify_url' => $this->notifyUrl,
            'amount' => [
                'total' => $amount,
                'currency' => 'CNY',
            ],
        ];

        $response = $this->request('v3/pay/transactions/native', 'POST', $payload);
        
        return [
            'code_url' => $response['code_url'],
            'prepay_id' => $response['prepay_id'],
        ];
    }

    /**
     * 查询订单状态
     */
    public function queryOrder(string $outTradeNo): array
    {
        $response = $this->request(
            "v3/pay/transactions/out-trade-no/{$outTradeNo}",
            'GET',
            null,
            ['mchid' => $this->mchId]
        );
        
        return [
            'trade_state' => $response['trade_state'],
            'trade_state_desc' => $response['trade_state_desc'],
        ];
    }

    /**
     * 处理支付回调
     */
    public function handleNotify(string $body, array $headers): array
    {
        // 验证签名
        $signature = $headers['wechatpay-signature'][0] ?? '';
        $timestamp = $headers['wechatpay-timestamp'][0] ?? '';
        $nonce = $headers['wechatpay-nonce'][0] ?? '';
        $serial = $headers['wechatpay-serial'][0] ?? '';
        
        $signContent = "{$timestamp}\n{$nonce}\n{$body}\n";
        
        $publicKey = openssl_get_publickey(file_get_contents(config('wechat_pay.wechat_cert_path')));
        $verified = openssl_verify(
            $signContent,
            base64_decode($signature),
            $publicKey,
            'sha256WithRSAEncryption'
        );

        if (!$verified) {
            throw new \Exception('签名验证失败');
        }

        // 解密回调数据
        $data = json_decode($body, true);
        $resource = $data['resource'];
        
        $decrypted = openssl_decrypt(
            base64_decode($resource['ciphertext']),
            'aes-256-gcm',
            $this->apiV3Key,
            OPENSSL_RAW_DATA,
            base64_decode($resource['nonce']),
            base64_decode($resource['associated_data'])
        );

        $notifyData = json_decode($decrypted, true);

        // 验证金额
        if ($notifyData['amount']['total'] !== $this->getOrderAmount($notifyData['out_trade_no'])) {
            throw new \Exception('金额不一致');
        }

        // 处理业务逻辑
        $this->processPaidOrder($notifyData);

        return [
            'code' => 'SUCCESS',
            'message' => 'OK',
        ];
    }

    /**
     * 发送 HTTP 请求(带签名)
     */
    protected function request(string $url, string $method, ?array $payload = null, array $query = []): array
    {
        $fullUrl = $url . ($query ? '?' . http_build_query($query) : '');
        $body = $payload ? json_encode($payload, JSON_UNESCAPED_UNICODE) : '';
        
        $timestamp = time();
        $nonce = \Illuminate\Support\Str::random(32);
        
        // 生成签名
        $signContent = "{$method}\n/{$fullUrl}\n{$timestamp}\n{$nonce}\n{$body}\n";
        openssl_sign($signContent, $signature, $this->privateKey, 'sha256WithRSAEncryption');
        
        $authorization = sprintf(
            'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%d",serial_no="%s"',
            $this->mchId,
            $nonce,
            base64_encode($signature),
            $timestamp,
            $this->serialNo
        );

        $response = $this->client->request($method, $fullUrl, [
            'headers' => [
                'Accept' => 'application/json',
                'Authorization' => $authorization,
                'Content-Type' => 'application/json',
            ],
            'body' => $body ?: null,
        ]);

        return json_decode($response->getBody(), true);
    }

    /**
     * 处理已支付订单(业务逻辑)
     */
    protected function processPaidOrder(array $notifyData): void
    {
        // TODO: 更新订单状态、发货、发送通知等
        Log::info('微信支付成功', $notifyData);
    }

    /**
     * 获取订单金额(用于验证)
     */
    protected function getOrderAmount(string $outTradeNo): int
    {
        // TODO: 从数据库查询订单金额
        return 0;
    }
}

3.2 创建支付控制器

<?php
// app/Http/Controllers/PaymentController.php

namespace App\Http\Controllers;

use App\Services\WechatPayService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

class PaymentController extends Controller
{
    protected $wechatPay;

    public function __construct(WechatPayService $wechatPay)
    {
        $this->wechatPay = $wechatPay;
    }

    /**
     * 创建支付订单
     */
    public function createOrder(Request $request)
    {
        $validated = $request->validate([
            'amount' => 'required|numeric|min:0.01',
            'description' => 'required|string|max:255',
            'product_id' => 'required|exists:products,id',
        ]);

        // 生成商户订单号
        $outTradeNo = 'ORD' . date('YmdHis') . Str::random(6);

        // 保存订单到数据库
        $order = DB::table('orders')->insertGetId([
            'out_trade_no' => $outTradeNo,
            'product_id' => $validated['product_id'],
            'amount' => $validated['amount'],
            'description' => $validated['description'],
            'status' => 'pending',
            'created_at' => now(),
        ]);

        // 调用微信支付
        $payResult = $this->wechatPay->createNativeOrder([
            'out_trade_no' => $outTradeNo,
            'amount' => (int)($validated['amount'] * 100), // 转换为分
            'description' => $validated['description'],
        ]);

        return response()->json([
            'success' => true,
            'data' => [
                'order_id' => $order,
                'out_trade_no' => $outTradeNo,
                'code_url' => $payResult['code_url'],
                'amount' => $validated['amount'],
            ],
        ]);
    }

    /**
     * 查询订单状态
     */
    public function queryOrder(string $outTradeNo)
    {
        $result = $this->wechatPay->queryOrder($outTradeNo);
        
        // 更新本地订单状态
        if ($result['trade_state'] === 'SUCCESS') {
            DB::table('orders')
                ->where('out_trade_no', $outTradeNo)
                ->update(['status' => 'paid', 'paid_at' => now()]);
        }

        return response()->json([
            'success' => true,
            'data' => $result,
        ]);
    }

    /**
     * 微信支付回调
     */
    public function notify(Request $request)
    {
        $body = $request->getContent();
        $headers = $request->headers->all();

        try {
            $result = $this->wechatPay->handleNotify($body, $headers);
            return response()->json($result);
        } catch (\Exception $e) {
            Log::error('微信支付回调失败', ['error' => $e->getMessage()]);
            return response()->json([
                'code' => 'FAIL',
                'message' => $e->getMessage(),
            ], 400);
        }
    }
}

3.3 配置路由

<?php
// routes/api.php

use App\Http\Controllers\PaymentController;

Route::prefix('pay')->group(function () {
    Route::post('/wechat/create', [PaymentController::class, 'createOrder']);
    Route::get('/wechat/query/{outTradeNo}', [PaymentController::class, 'queryOrder']);
    Route::post('/wechat/notify', [PaymentController::class, 'notify']); // 微信支付回调
});

3.4 配置文件

<?php
// config/wechat_pay.php

return [
    'appid' => env('WX_PAY_APPID'),
    'mchid' => env('WX_PAY_MCHID'),
    'serial_no' => env('WX_PAY_SERIAL_NO'),
    'private_key_path' => storage_path(env('WX_PAY_PRIVATE_KEY_PATH')),
    'wechat_cert_path' => storage_path(env('WX_PAY_WECHAT_CERT_PATH')),
    'api_v3_key' => env('WX_PAY_API_V3_KEY'),
    'notify_url' => env('WX_PAY_NOTIFY_URL'),
];

四、Vue 前端实现

4.1 支付组件

<!-- components/WechatPay.vue -->

<template>
  <div class="wechat-pay-container">
    <div class="pay-info">
      <h3>订单金额:¥{{ amount }}</h3>
      <p>{{ description }}</p>
    </div>

    <div v-if="loading" class="loading">
      <span>正在生成支付二维码...</span>
    </div>

    <div v-else-if="qrCodeUrl" class="qr-code-box">
      <qrcode-vue 
        :value="qrCodeUrl" 
        :size="200" 
        level="H"
      />
      <p class="tips">请使用微信扫码支付</p>
      
      <div class="countdown" v-if="!paid">
        支付剩余时间:<span>{{ countdownTime }}</span>
      </div>
    </div>

    <div v-if="paid" class="success-box">
      <el-icon color="#67C23A"><Check /></el-icon>
      <span>支付成功!</span>
    </div>

    <div class="actions">
      <el-button @click="checkPaymentStatus" :disabled="paid">
        查询支付状态
      </el-button>
      <el-button @click="refreshQrCode" v-if="qrCodeExpired">
        刷新二维码
      </el-button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import QrcodeVue from 'qrcode.vue'
import { Check } from '@element-plus/icons-vue'
import axios from 'axios'

const props = defineProps({
  amount: { type: Number, required: true },
  description: { type: String, required: true },
  productId: { type: Number, required: true }
})

const qrCodeUrl = ref('')
const outTradeNo = ref('')
const loading = ref(true)
const paid = ref(false)
const qrCodeExpired = ref(false)
const countdownTime = ref('15:00')

let pollTimer = null
let countdownTimer = null
let expireTime = 0

// 创建支付订单
const createOrder = async () => {
  try {
    const res = await axios.post('/api/pay/wechat/create', {
      amount: props.amount,
      description: props.description,
      product_id: props.productId
    })
    
    if (res.data.success) {
      qrCodeUrl.value = res.data.data.code_url
      outTradeNo.value = res.data.data.out_trade_no
      expireTime = Date.now() + 15 * 60 * 1000 // 15 分钟过期
      startCountdown()
      startPolling()
    }
  } catch (error) {
    console.error('创建订单失败', error)
    ElMessage.error('创建订单失败,请重试')
  } finally {
    loading.value = false
  }
}

// 轮询检查支付状态
const startPolling = () => {
  pollTimer = setInterval(async () => {
    if (paid.value || qrCodeExpired.value) return
    
    try {
      const res = await axios.get(`/api/pay/wechat/query/${outTradeNo.value}`)
      if (res.data.data.trade_state === 'SUCCESS') {
        paid.value = true
        stopPolling()
        ElMessage.success('支付成功!')
        // 跳转或刷新页面
        setTimeout(() => {
          window.location.reload()
        }, 2000)
      }
    } catch (error) {
      console.error('查询支付状态失败', error)
    }
  }, 3000) // 每 3 秒查询一次
}

const stopPolling = () => {
  if (pollTimer) {
    clearInterval(pollTimer)
    pollTimer = null
  }
}

// 倒计时
const startCountdown = () => {
  countdownTimer = setInterval(() => {
    const remaining = expireTime - Date.now()
    if (remaining <= 0) {
      qrCodeExpired.value = true
      stopCountdown()
      stopPolling()
      ElMessage.warning('二维码已过期,请刷新')
      return
    }
    
    const minutes = Math.floor(remaining / 60000)
    const seconds = Math.floor((remaining % 60000) / 1000)
    countdownTime.value = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
  }, 1000)
}

const stopCountdown = () => {
  if (countdownTimer) {
    clearInterval(countdownTimer)
    countdownTimer = null
  }
}

// 手动查询支付状态
const checkPaymentStatus = async () => {
  try {
    const res = await axios.get(`/api/pay/wechat/query/${outTradeNo.value}`)
    if (res.data.data.trade_state === 'SUCCESS') {
      paid.value = true
      ElMessage.success('支付成功!')
    } else {
      ElMessage.info(`当前状态:${res.data.data.trade_state_desc}`)
    }
  } catch (error) {
    ElMessage.error('查询失败')
  }
}

// 刷新二维码
const refreshQrCode = () => {
  qrCodeExpired.value = false
  paid.value = false
  loading.value = true
  qrCodeUrl.value = ''
  createOrder()
}

onMounted(() => {
  createOrder()
})

onUnmounted(() => {
  stopPolling()
  stopCountdown()
})
</script>

<style scoped>
.wechat-pay-container {
  max-width: 400px;
  margin: 0 auto;
  padding: 20px;
  text-align: center;
}

.pay-info {
  margin-bottom: 20px;
}

.qr-code-box {
  padding: 20px;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}

.tips {
  color: #909399;
  margin-top: 10px;
  font-size: 14px;
}

.countdown {
  margin-top: 15px;
  color: #F56C6C;
  font-weight: bold;
}

.success-box {
  padding: 20px;
  color: #67C23A;
  font-size: 18px;
}

.actions {
  margin-top: 20px;
}

.loading {
  padding: 40px;
  color: #909399;
}
</style>

4.2 使用组件

<!-- pages/OrderPay.vue -->

<template>
  <div class="pay-page">
    <h1>订单支付</h1>
    <WechatPay 
      :amount="order.amount"
      :description="order.description"
      :product-id="order.productId"
    />
  </div>
</template>

<script setup>
import WechatPay from '@/components/WechatPay.vue'

const order = {
  amount: 99.00,
  description: 'VIP 会员订阅',
  productId: 1
}
</script>

五、安全与优化

5.1 回调验签中间件

<?php
// app/Http/Middleware/VerifyWechatNotify.php

namespace App\Http\Middleware;

use Closure;

class VerifyWechatNotify
{
    public function handle($request, Closure $next)
    {
        if ($request->is('api/pay/wechat/notify')) {
            // 只允许微信服务器 IP
            $wechatIps = ['140.143.0.0/16', '119.147.0.0/16'];
            // 实际应使用微信官方 IP 列表
        }
        
        return $next($request);
    }
}

5.2 订单防重处理

<?php
// 使用 Redis 防止重复回调
$cacheKey = "wechat_notify:{$outTradeNo}";
if (Cache::has($cacheKey)) {
    return ['code' => 'SUCCESS', 'message' => '已处理'];
}
Cache::set($cacheKey, 1, 3600);

5.3 对账与退款

<?php
// 下载对账单
public function downloadBill(string $date)
{
    return $this->request(
        "v3/bill/tradebill?bill_date={$date}",
        'GET'
    );
}

// 申请退款
public function refund(string $outTradeNo, int $amount)
{
    $payload = [
        'out_trade_no' => $outTradeNo,
        'out_refund_no' => 'REF' . time(),
        'amount' => [
            'refund' => $amount,
            'total' => $amount,
            'currency' => 'CNY',
        ],
    ];
    
    return $this->request('v3/refund/domestic/refunds', 'POST', $payload);
}

六、常见问题排查

问题原因解决方案
签名验证失败证书/密钥错误重新下载证书,检查路径
回调收不到域名未备案/HTTPS使用备案域名 + HTTPS
二维码过期超过 15 分钟前端实现刷新机制
金额不一致前后端计算误差统一使用"分"为单位
订单状态不同步回调处理失败增加轮询 + 手动查询

七、生产部署建议

项目建议
🔒 HTTPS必须使用,微信支付强制要求
📝 日志记录所有支付请求和回调
🔄 幂等性回调处理必须支持重复调用
💾 数据备份订单数据定期备份
📊 监控告警支付失败率超过阈值告警