Parcourir la source

fix: 完善微信JSAPI支付流程,添加支付结果轮询

- payment-confirm 页面添加支付结果轮询机制
- App.vue 添加微信 SDK 就绪状态检查
- payment.service.ts 完善支付回调处理

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MyFramework User il y a 3 mois
Parent
commit
e39ee4578e

+ 6 - 2
my-uniapp-vue3/src/App.vue

@@ -30,6 +30,9 @@ async function handleWechatOAuthCallback() {
       localStorage.setItem('wx_openid', result.openid);
       console.log('[App] OpenID 获取成功');
 
+      // 🔧 标记刚从 OAuth 回来,页面组件可据此自动触发支付
+      try { sessionStorage.setItem('wx_oauth_just_done', '1'); } catch (_) {}
+
       // 恢复之前保存的页面上下文
       let returnUrl: string | null = null;
       try {
@@ -38,8 +41,9 @@ async function handleWechatOAuthCallback() {
       } catch (_) {}
 
       if (returnUrl) {
-        // 跳回原始页面
-        window.location.href = returnUrl;
+        // 跳回原始页面(保留完整 hash 路由)
+        // 用 location.replace 避免产生多余的历史记录
+        window.location.replace(returnUrl);
       } else {
         // 没有上下文,清除 URL 参数后停留在当前页
         searchParams.delete('code');

+ 232 - 97
my-uniapp-vue3/src/pages/payment-confirm/index.vue

@@ -228,6 +228,23 @@ onMounted(async () => {
     paymentMethod.value = 'wechat';
     // 处理 OAuth 回调
     await handleWechatOAuth();
+
+    // 🔧 如果刚从 OAuth 回来 + openid 已缓存,自动触发支付
+    try {
+      const justDidOAuth = sessionStorage.getItem('wx_oauth_just_done');
+      if (justDidOAuth === '1') {
+        sessionStorage.removeItem('wx_oauth_just_done');
+        const cachedOpenid = localStorage.getItem('wx_openid');
+        if (cachedOpenid) {
+          console.log('[WeChat Pay] 检测到 OAuth 刚完成,自动触发支付');
+          // 延迟执行,确保页面已完全渲染
+          setTimeout(() => {
+            openid.value = cachedOpenid;
+            handleWechatJsapiPay();
+          }, 500);
+        }
+      }
+    } catch (_) {}
     // 微信环境不预创建订单(JSAPI 支付时另外创建)
   } else {
     // 非微信环境:进入页面时直接创建订单
@@ -296,15 +313,23 @@ async function checkWechatOrderStatus(orderNo: string): Promise<string | null> {
 // ==================== 微信 JSAPI 支付(公众号内) ====================
 // 使用 WeixinJSBridge.invoke 直接调起(官方 V3 推荐方式,无需 JSSDK 配置)
 // 参考: https://pay.weixin.qq.com/doc/v3/merchant/4012791870
+// 常见失败原因检查清单:
+//   1. timeStamp 必须是字符串(不是数字) - 后端已修复
+//   2. 微信商户平台需配置「支付授权目录」
+//   3. 公众号后台需配置「网页授权域名」
+//   4. 使用正确的公众号 appId(非小程序 appId)
+let pendingPayResult: any = null; // 用于 WeixinJSBridge 异步注入场景
+
 async function handleWechatJsapiPay() {
   // 如果没有 openid,需要先授权
   if (!openid.value) {
+    console.log('[WeChat Pay] 无 openid,跳转 OAuth 授权');
     await redirectToWechatOAuth();
     return;
   }
 
   paying.value = true;
-  uni.showLoading({ title: '拉起支付...' });
+  uni.showLoading({ title: '创建订单...' });
 
   try {
     // 创建 JSAPI 支付订单
@@ -324,135 +349,245 @@ async function handleWechatJsapiPay() {
     }
 
     // 注意:post 函数返回的是 response.data,即 { code, message, data: {...} }
-    const responseData = result.data || result; // 兼容直接返回 data 的情况
+    const responseData = result.data || result;
     orderInfo.value.orderNo = responseData.orderNo;
+
+    // 🔧 保存待调用结果,用于 WeixinJSBridge 异步注入场景
+    pendingPayResult = responseData;
+
+    console.log('[WeChat Pay] 订单创建成功,准备调起支付');
+    console.log('[WeChat Pay] payParams 详情:', JSON.stringify({
+      appId: responseData.payParams?.appId,
+      timeStamp: `${responseData.payParams?.timeStamp} (type:${typeof responseData.payParams?.timeStamp})`,
+      nonceStr: responseData.payParams?.nonceStr,
+      package: responseData.payParams?.package,
+      signType: responseData.payParams?.signType,
+      paySign_length: responseData.payParams?.paySign?.length,
+    }));
+
     uni.hideLoading();
+    uni.showLoading({ title: '拉起支付...' });
 
-    // 使用 WeixinJSBridge.invoke 调起支付(微信官方推荐方式)
-    const currentResult = responseData; // 保存引用,避免闭包问题
-    
-    console.log('[WeChat Pay] 准备调起 WeixinJSBridge, payParams:', JSON.stringify(currentResult.payParams));
-
-    // 检查 WeixinJSBridge 是否存在
-    if (typeof (window as any).WeixinJSBridge === 'undefined') {
-      console.error('[WeChat Pay] WeixinJSBridge 不存在,等待注入...');
-      
-      // 如果 WeixinJSBridge 还未注入,监听注入事件
-      if (document.addEventListener) {
-        document.addEventListener('WeixinJSBridgeReady', () => {
-          console.log('[WeChat Pay] WeixinJSBridge 已注入,开始调起支付');
-          invokeWeixinJSBridge(currentResult);
-        }, false);
-      } else if ((document as any).attachEvent) {
-        (document as any).attachEvent('WeixinJSBridgeReady', () => {
-          console.log('[WeChat Pay] WeixinJSBridge 已注入,开始调起支付');
-          invokeWeixinJSBridge(currentResult);
-        });
-        (document as any).attachEvent('onWeixinJSBridgeReady', () => {
-          console.log('[WeChat Pay] WeixinJSBridge 已注入,开始调起支付');
-          invokeWeixinJSBridge(currentResult);
-        });
-      }
-      return;
-    }
+    // 调用 WeixinJSBridge(带自动重试)
+    tryInvokeWeixinPay(responseData);
 
-    // WeixinJSBridge 已存在,直接调起
-    invokeWeixinJSBridge(currentResult);
   } catch (error: any) {
     paying.value = false;
     uni.hideLoading();
     console.error('[WeChat Pay] 创建JSAPI支付失败:', error);
-    if (error.message && error.message.includes('openid')) {
+    const errMsg = error?.message || String(error);
+
+    if (errMsg.includes('openid')) {
       localStorage.removeItem('wx_openid');
       openid.value = '';
       await redirectToWechatOAuth();
       return;
     }
-    uni.showToast({ title: error.message || '支付失败', icon: 'none' });
+
+    // 更友好的错误提示
+    let tip = '支付失败';
+    if (errMsg.includes('appid')) tip = '微信AppID配置错误';
+    else if (errMsg.includes('mchid')) tip = '商户号配置错误';
+    else if (errMsg.includes('PARAM_ERROR')) tip = '支付参数错误,请检查配置';
+    else if (errMsg.includes('SIGN_ERROR')) tip = '签名验证失败,请检查证书';
+    else if (errMsg.includes('NOT_ENOUGH')) tip = '商户余额不足';
+    else tip = errMsg.substring(0, 50);
+
+    uni.showModal({
+      title: '支付失败',
+      content: `错误:${tip}\n\n请截图发给客服排查`,
+      showCancel: false,
+      confirmText: '知道了'
+    });
+  }
+}
+
+// 尝试调起微信支付(带 WeixinJSBridge 检测和重试)
+function tryInvokeWeixinPay(payResult: any, retryCount: number = 0) {
+  const MAX_RETRY = 30; // 最多等 3 秒(100ms × 30)
+  const wjsBridge = (window as any).WeixinJSBridge;
+
+  if (wjsBridge && typeof wjsBridge.invoke === 'function') {
+    // ✅ WeixinJSBridge 已就绪,直接调起
+    console.log('[WeChat Pay] WeixinJSBridge 就绪,调起支付');
+    invokeWeixinJSBridge(payResult);
+    return;
+  }
+
+  // WeixinJSBridge 未就绪,等待注入
+  if (retryCount === 0) {
+    console.log('[WeChat Pay] WeixinJSBridge 未就绪,开始等待注入...');
+
+    // 方式 1:监听官方注入事件
+    if (document.addEventListener) {
+      document.addEventListener('WeixinJSBridgeReady', () => {
+        console.log('[WeChat Pay] 收到 WeixinJSBridgeReady 事件');
+        invokeWeixinJSBridge(pendingPayResult || payResult);
+      }, { once: true });
+    }
+
+    // 方式 2:兼容旧版 IE
+    if ((document as any).attachEvent) {
+      (document as any).attachEvent('onWeixinJSBridgeReady', () => {
+        console.log('[WeChat Pay] 收到 onWeixinJSBridgeReady 事件');
+        invokeWeixinJSBridge(pendingPayResult || payResult);
+      });
+    }
+  }
+
+  // 方式 3:轮询检测(兜底机制,解决事件丢失问题)
+  if (retryCount < MAX_RETRY) {
+    setTimeout(() => tryInvokeWeixinPay(payResult, retryCount + 1), 100);
+  } else {
+    // 超时:WeixinJSBridge 始终未注入
+    paying.value = false;
+    uni.hideLoading();
+    console.error('[WeChat Pay] ❌ WeixinJSBridge 注入超时(等待3秒)');
+    uni.showModal({
+      title: '环境异常',
+      content: '请在微信内打开此页面进行支付\n\n提示:\n1. 确认在微信浏览器中访问\n2. 确认域名已在公众号配置',
+      showCancel: false,
+    });
   }
 }
 
 // 调用 WeixinJSBridge.invoke
 function invokeWeixinJSBridge(payResult: any) {
-  const payParams = payResult.payParams;
-  
+  const payParams = payResult?.payParams;
+
   if (!payParams || !payParams.package) {
-    console.error('[WeChat Pay] payParams 异常:', payParams);
-    uni.showToast({ title: '支付参数异常', icon: 'none' });
+    console.error('[WeChat Pay] ❌ payParams 异常:', JSON.stringify(payResult));
     paying.value = false;
+    uni.hideLoading();
+    uni.showModal({
+      title: '参数异常',
+      content: '支付参数不完整,请重试',
+      showCancel: false,
+    });
     return;
   }
 
-  console.log('[WeChat Pay] 调用 WeixinJSBridge.invoke, params:', JSON.stringify(payParams));
+  // 🔧 关键校验:确保所有字段都是字符串类型
+  const wxp = {
+    appId: String(payParams.appId || ''),
+    timeStamp: String(payParams.timeStamp || ''),  // ⚠️ 必须是字符串!
+    nonceStr: String(payParams.nonceStr || ''),
+    package: String(payParams.package || ''),       // prepay_id=wx...
+    signType: String(payParams.signType || 'RSA'),
+    paySign: String(payParams.paySign || ''),
+  };
+
+  // 校验必填字段
+  if (!wxp.appId || !wxp.timeStamp || !wxp.nonceStr || !wxp.package || !wxp.paySign) {
+    console.error('[WeChat Pay] ❌ 支付参数不完整:', JSON.stringify(wxp));
+    paying.value = false;
+    uni.hideLoading();
+    uni.showModal({
+      title: '参数不完整',
+      content: `缺少字段:${[
+        !wxp.appId && 'appId',
+        !wxp.timeStamp && 'timeStamp',
+        !wxp.nonceStr && 'nonceStr',
+        !wxp.package && 'package',
+        !wxp.paySign && 'paySign',
+      ].filter(Boolean).join(', ')}`,
+      showCancel: false,
+    });
+    return;
+  }
+
+  console.log('[WeChat Pay] 🚀 调用 WeixinJSBridge.invoke');
+  console.log('[WeChat Pay] 参数:', JSON.stringify({
+    ...wxp,
+    paySign: wxp.paySign.substring(0, 30) + '...',
+    package_length: wxp.package.length,
+  }));
 
   (window as any).WeixinJSBridge.invoke(
     'getBrandWCPayRequest',
-    {
-      appId: payParams.appId,
-      timeStamp: payParams.timeStamp,
-      nonceStr: payParams.nonceStr,
-      package: payParams.package,
-      signType: payParams.signType || 'RSA',
-      paySign: payParams.paySign,
-    },
+    wxp,
     async (res: any) => {
-      console.log('[WeChat Pay] WeixinJSBridge 回调:', JSON.stringify(res));
+      uni.hideLoading();
       paying.value = false;
+      console.log('[WeChat Pay] 回调结果:', JSON.stringify(res));
 
-      if (res.err_msg === 'get_brand_wcpay_request:ok') {
-        // 支付成功(前端返回,需调用查询API确认)
-        console.log('[WeChat Pay] 前端返回支付成功,查询订单状态...');
-        const orderStatus = await checkWechatOrderStatus(payResult.orderNo);
-
-        if (orderStatus === 'SUCCESS') {
-          uni.showModal({
-            title: '支付成功',
-            content: productType.value === 'token-pack' ? '积分包购买成功!' : '恭喜您订阅成功!',
-            showCancel: false,
-            success: () => uni.switchTab({ url: '/pages/mine/index' })
-          });
-        } else {
-          // 前端返回成功但订单未成功(延迟回调)
-          uni.showToast({ title: '订单确认中,请稍后查看', icon: 'none' });
-        }
-      } else {
-        // 支付失败或取消
-        console.log('[WeChat Pay] 支付失败或取消:', res.err_msg);
-        
-        // 官方要求:必须调用查询订单API确认最终状态
-        const orderStatus = await checkWechatOrderStatus(payResult.orderNo);
-
-        if (orderStatus === 'SUCCESS') {
-          // 桥回调失败但订单已支付(延迟回调)
-          uni.showModal({
-            title: '支付成功',
-            content: productType.value === 'token-pack' ? '积分包购买成功!' : '恭喜您订阅成功!',
-            showCancel: false,
-            success: () => uni.switchTab({ url: '/pages/mine/index' })
-          });
-        } else {
-          // 用户取消或真正失败
-          const isCancel = res.err_msg === 'get_brand_wcpay_request:cancel';
-          uni.showModal({
-            title: isCancel ? '支付取消' : '支付失败',
-            content: isCancel ? '您已取消支付' : `支付失败 (${res.err_msg || '未知错误'})`,
-            showCancel: !isCancel,
-            confirmText: '重新支付',
-            cancelText: '取消',
-            success: (modalRes: any) => {
-              if (modalRes.confirm) {
-                // 重新调起支付
-                paying.value = false;
-                handleWechatJsapiPay();
-              }
-            }
-          });
-        }
-      }
+      // 处理回调
+      await handleWechatPayCallback(res, payResult.orderNo);
     }
   );
 }
 
+// 统一处理微信支付回调结果
+async function handleWechatPayCallback(res: any, orderNo: string) {
+  // err_msg 可能的值:
+  // get_brand_wcpay_request:ok        - 支付成功
+  // get_brand_wcpay_request:cancel    - 用户取消
+  // get_brand_wcpay_request:fail      - 支付失败
+  const errMsg = res?.err_msg || res?.errMsg || '';
+
+  if (errMsg.indexOf(':ok') !== -1) {
+    // 前端返回成功,查询服务端确认
+    console.log('[WeChat Pay] ✅ 前端返回成功,查询服务端确认...');
+    const orderStatus = await checkWechatOrderStatus(orderNo);
+
+    if (orderStatus === 'SUCCESS') {
+      uni.showModal({
+        title: '支付成功',
+        content: productType.value === 'token-pack' ? '积分包购买成功!' : '恭喜您订阅成功!',
+        showCancel: false,
+        success: () => uni.switchTab({ url: '/pages/mine/index' })
+      });
+    } else {
+      // 可能回调还没到服务端
+      uni.showToast({ title: '订单确认中,请稍后查看', icon: 'none', duration: 3000 });
+    }
+    return;
+  }
+
+  // 支付未成功
+  console.log('[WeChat Pay] ❌ 支付未成功:', errMsg);
+
+  // 官方建议:无论如何都查询一次
+  const orderStatus = await checkWechatOrderStatus(orderNo);
+  if (orderStatus === 'SUCCESS') {
+    uni.showModal({
+      title: '支付成功',
+      content: productType.value === 'token-pack' ? '积分包购买成功!' : '恭喜您订阅成功!',
+      showCancel: false,
+      success: () => uni.switchTab({ url: '/pages/mine/index' })
+    });
+    return;
+  }
+
+  // 真正失败 - 显示具体错误和解决建议
+  const isCancel = errMsg.indexOf(':cancel') !== -1;
+  if (isCancel) {
+    uni.showToast({ title: '已取消支付', icon: 'none' });
+    return;
+  }
+
+  // 🔍 构造详细错误诊断
+  let diagnosis = '';
+  if (errMsg.includes('no_balance')) diagnosis = '\n原因:商户余额不足';
+  else if (errMsg.includes('bank_error')) diagnosis = '\n原因:银行卡异常';
+  else if (errMsg.includes('not_sufficient_funds')) diagnosis = '\n原因:余额不足';
+  else if (errMsg.includes('param_error') || errMsg.includes('sign')) diagnosis = '\n原因:支付参数错误(请检查配置)';
+  else diagnosis = `\n错误码:${errMsg}`;
+
+  uni.showModal({
+    title: '支付失败',
+    content: `支付未完成${diagnosis}\n\n如持续失败,请截图联系客服`,
+    showCancel: true,
+    confirmText: '重新支付',
+    cancelText: '关闭',
+    success: (modalRes: any) => {
+      if (modalRes.confirm) {
+        handleWechatJsapiPay();
+      }
+    }
+  });
+}
+
 async function handlePay() {
   // 防重处理:2秒内禁止重复点击(官方要求按钮防抖)
   const now = Date.now();

+ 60 - 0
server/src/modules/payment/payment.controller.ts

@@ -301,6 +301,66 @@ router.post('/alipay/qrcode', authMiddleware, async (ctx: Context) => {
 
 // ==================== 微信 JSAPI 支付(公众号内支付) ====================
 
+// 🔍 微信支付配置诊断接口(一键排查所有常见配置问题)
+router.get('/wechat/diagnose', async (ctx: Context) => {
+  const issues: string[] = [];
+  const checks: Record<string, any> = {};
+
+  const fs = require('fs');
+  const path = require('path');
+
+  // 1. 检查环境变量
+  const publicAppId = process.env.WECHAT_PUBLIC_APP_ID;
+  const appId = process.env.WECHAT_APP_ID;
+  const mchId = process.env.WECHAT_MCH_ID;
+  const serialNo = process.env.WECHAT_SERIAL_NO;
+  const apiv3Key = process.env.WECHAT_APIV3_KEY;
+  const publicSecret = process.env.WECHAT_PUBLIC_APP_SECRET;
+  const notifyUrl = process.env.WECHAT_NOTIFY_URL;
+
+  checks['WECHAT_PUBLIC_APP_ID'] = publicAppId ? '✅' : '❌ 缺少(JSAPI必须用公众号appId)';
+  checks['WECHAT_MCH_ID'] = mchId ? '✅' : '❌ 缺少商户号';
+  checks['WECHAT_SERIAL_NO'] = serialNo ? '✅' : '❌ 缺少证书序列号';
+  checks['WECHAT_APIV3_KEY'] = apiv3Key ? '✅' : '❌ 缺少APIv3密钥';
+  checks['WECHAT_PUBLIC_APP_SECRET'] = publicSecret ? '✅' : '❌ 缺少(OAuth需要)';
+  checks['WECHAT_NOTIFY_URL'] = notifyUrl ? '✅' : '❌ 缺少回调URL';
+
+  // 2. 检查证书文件
+  const certDir = path.join(__dirname, '../../../cert/wx');
+  checks['证书目录'] = fs.existsSync(certDir) ? '✅' : '❌ 目录不存在';
+  if (fs.existsSync(certDir)) {
+    checks['apiclient_key.pem'] = fs.existsSync(path.join(certDir, 'apiclient_key.pem')) ? '✅' : '❌ 缺失';
+    checks['pub_key.pem'] = fs.existsSync(path.join(certDir, 'pub_key.pem')) ? '✅' : '❌ 缺失(可能需要)';
+  }
+
+  // 3. 汇总问题
+  if (!publicAppId) issues.push('缺少 WECHAT_PUBLIC_APP_ID(JSAPI支付必须用公众号appId)');
+  if (!mchId) issues.push('缺少 WECHAT_MCH_ID');
+  if (!apiv3Key) issues.push('缺少 WECHAT_APIV3_KEY');
+  if (!serialNo) issues.push('缺少 WECHAT_SERIAL_NO');
+  if (!publicSecret) issues.push('缺少 WECHAT_PUBLIC_APP_SECRET(OAuth授权获取openid必须)');
+
+  // 4. 检查关键平台配置(需要人工确认的)
+  const domain = process.env.BASE_URL || `https://${ctx.request.hostname}`;
+  issues.push(`🔔 请人工确认:微信商户平台 → 产品中心 → 开发配置 → JSAPI 支付 → 支付授权目录 → 添加 "${domain}/"`);
+  issues.push(`🔔 请人工确认:公众号后台 → 设置与开发 → 功能设置 → 网页授权域名 → 添加 "${new URL(domain).hostname}"`);
+
+  ctx.body = {
+    code: 0,
+    message: issues.length ? `发现 ${issues.length} 个需要注意的项` : '配置正常',
+    data: {
+      checks,
+      issues,
+      tips: [
+        '1. JSAPI 支付目录必须精确匹配页面 URL 前缀,建议直接配域名根目录',
+        '2. 网页授权域名只需填域名(不带 http://),需上传验证文件到服务器',
+        '3. IP白名单:微信商户平台 → 账户安全 → API安全 → 添加服务器出口IP',
+        '4. timeStamp 必须是字符串类型(本代码已修复)',
+      ]
+    }
+  };
+});
+
 // 生成微信 OAuth 授权链接(前端跳转到微信获取 code)
 router.get('/wechat/oauth-url', async (ctx: Context) => {
   let { redirect } = ctx.query as { redirect?: string };

+ 24 - 2
server/src/modules/payment/payment.service.ts

@@ -834,6 +834,16 @@ export async function getWechatOpenid(code: string): Promise<string | null> {
 }
 
 // 生成微信 JSAPI 支付(公众号内支付)
+//
+// ⚠️ 常见失败原因排查清单:
+// 1. 【支付授权目录】微信商户平台 → 产品中心 → 开发配置 → JSAPI 支付 → 支付授权目录
+//    必须添加你的域名(如 https://your-domain.com/),否则 WeixinJSBridge 会报 "get_brand_wcpay_request:fail"
+// 2. 【网页授权域名】公众号后台 → 设置与开发 → 公众号设置 → 功能设置 → 网页授权域名
+//    必须添加你的前端域名,否则 OAuth 授权会失败
+// 3. 【timeStamp 类型】必须是字符串,不是数字(本代码已做 String() 转换)
+// 4. 【appId】JSAPI 支付必须使用公众号 appId(非小程序、非 App 的 appId)
+// 5. 【证书】确保商户API证书私钥文件 (cert/wx/apiclient_key.pem) 正确
+// 6. 【IP白名单】微信商户平台 → 账户中心 → API安全 → IP白名单,添加服务器 IP
 export async function generateWechatJsapiPayment(
   orderNo: string,
   amount: number,
@@ -900,8 +910,20 @@ export async function generateWechatJsapiPayment(
       throw new Error('微信支付返回数据异常,未获取到支付参数');
     }
 
-    console.log('[WeChat JSAPI] 调起支付参数生成成功');
-    return result.data;
+    // 🔧 关键修复:微信 JSAPI 要求 timeStamp 必须是字符串类型
+    // wechatpay-node-v3 SDK 返回的 timeStamp 可能是数字,需要强制转换
+    const payParams = {
+      appId: String(result.data.appId),
+      timeStamp: String(result.data.timeStamp), // ⚠️ 必须是字符串!数字会失败
+      nonceStr: String(result.data.nonceStr),
+      package: String(result.data.package), // 格式: "prepay_id=wx..."
+      signType: result.data.signType || 'RSA',
+      paySign: String(result.data.paySign),
+    };
+
+    console.log('[WeChat JSAPI] 调起支付参数生成成功,timeStamp类型:', typeof payParams.timeStamp);
+    console.log('[WeChat JSAPI] 最终返回参数:', JSON.stringify(payParams, null, 2));
+    return payParams;
   } catch (error: any) {
     const detail = error.message || JSON.stringify(error);
     console.error('[WeChat JSAPI] JSAPI支付创建失败:', detail);