💰 Vue + Laravel 微信支付 Native 实战:从接入到上线,完整避坑指南
前后端分离架构下的微信支付完整解决方案,2026 年最新版
一、前言
在电商、SaaS、知识付费等场景中,微信支付是必不可少的功能。本文将以 Vue 3 + Laravel 10/11 为例,详细讲解如何接入微信 Native 支付(扫码支付),涵盖从申请商户号到生产上线的全流程。
为什么选择 Native 支付?
| 支付方式 | 适用场景 | 优点 |
|---|
| Native 支付 | PC 网站、H5 页面扫码 | 用户体验好,转化率高 |
| JSAPI 支付 | 微信公众号内 | 无需扫码,直接支付 |
| H5 支付 | 手机浏览器 | 支持外部浏览器 |
| 小程序支付 | 微信小程序 | 生态内闭环 |
二、准备工作
2.1 申请微信支付商户号
- 登录 微信支付商户平台
- 完成企业认证
- 获取以下关键信息:
| 参数 | 说明 | 获取位置 |
|---|
appid | 公众号/小程序 AppID | 微信开放平台 |
mchid | 商户号 | 商户平台 → 账户中心 |
serial_no | 商户证书序列号 | 商户平台 → API 安全 |
private_key | 商户私钥 | 下载 apiclient_key.pem |
wechat_cert | 微信平台证书 | 接口获取或下载 |
api_key | API v3 密钥 | 商户平台 → API 安全 |
2.2 安装依赖
composer require yansongda/pay
composer require guzzlehttp/guzzle
npm install qrcode.vue
npm install axios
2.3 配置环境变量
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
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'),
]);
}
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',
];
}
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
{
Log::info('微信支付成功', $notifyData);
}
protected function getOrderAmount(string $outTradeNo): int
{
return 0;
}
}
3.2 创建支付控制器
<?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
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
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 支付组件
<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
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)
}
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 使用组件
<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
namespace App\Http\Middleware;
use Closure;
class VerifyWechatNotify
{
public function handle($request, Closure $next)
{
if ($request->is('api/pay/wechat/notify')) {
$wechatIps = ['140.143.0.0/16', '119.147.0.0/16'];
}
return $next($request);
}
}
5.2 订单防重处理
<?php
$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 | 必须使用,微信支付强制要求 |
| 📝 日志 | 记录所有支付请求和回调 |
| 🔄 幂等性 | 回调处理必须支持重复调用 |
| 💾 数据备份 | 订单数据定期备份 |
| 📊 监控告警 | 支付失败率超过阈值告警 |