This commit is contained in:
Mrx
2026-06-07 15:11:30 +08:00
parent 777dc1e474
commit 42581ffe8a
6 changed files with 331 additions and 25 deletions

View File

@@ -1,5 +1,6 @@
import un from '@uni-helper/uni-network' import un from '@uni-helper/uni-network'
import { QNC_API } from '@/config/api' import { QNC_API } from '@/config/api'
import { safeHideLoading } from '@/utils/loading'
/** 未单独配置登录页时401 回到登录页 */ /** 未单独配置登录页时401 回到登录页 */
const AUTH_FALLBACK_PAGE = '/pages/login' const AUTH_FALLBACK_PAGE = '/pages/login'
@@ -18,12 +19,7 @@ function showRequestLoading() {
function hideRequestLoading() { function hideRequestLoading() {
if (--loadingCount <= 0) { if (--loadingCount <= 0) {
loadingCount = 0 loadingCount = 0
try { safeHideLoading()
uni.hideLoading()
}
catch {
// 微信小程序真机调试时,如果没有 loading 显示会报错 "toast can't be found",忽略即可
}
} }
} }

View File

@@ -11,3 +11,11 @@ export async function postPayCheck(body, requestConfig) {
const res = await http.post('/pay/check', body, requestConfig) const res = await http.post('/pay/check', body, requestConfig)
return res.data return res.data
} }
/**
* 上报虚拟支付客户端步骤/微信·Apple 提示语(写入后端日志,并触发 query_order 快照)
*/
export async function postXpayClientEvent(body, requestConfig) {
const res = await http.post('/pay/xpay/client-event', body, requestConfig)
return res.data
}

View File

@@ -1,5 +1,6 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { safeHideLoading } from '@/utils/loading'
import { fetchReportShareUrl } from '@/utils/reportH5Link' import { fetchReportShareUrl } from '@/utils/reportH5Link'
const props = defineProps({ const props = defineProps({
@@ -23,7 +24,7 @@ async function copyLink() {
orderId: props.orderId, orderId: props.orderId,
orderNo: props.orderNo, orderNo: props.orderNo,
}) })
uni.hideLoading() safeHideLoading()
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
uni.setClipboardData({ uni.setClipboardData({
data: url, data: url,
@@ -34,7 +35,7 @@ async function copyLink() {
uni.showToast({ title: '链接已复制,可在浏览器打开', icon: 'none', duration: 2500 }) uni.showToast({ title: '链接已复制,可在浏览器打开', icon: 'none', duration: 2500 })
} }
catch (e) { catch (e) {
uni.hideLoading() safeHideLoading()
uni.showToast({ uni.showToast({
title: e?.message || '复制失败', title: e?.message || '复制失败',
icon: 'none', icon: 'none',

View File

@@ -2,7 +2,7 @@
import { onLoad, onReady } from '@dcloudio/uni-app' import { onLoad, onReady } from '@dcloudio/uni-app'
import { computed, onUnmounted, ref } from 'vue' import { computed, onUnmounted, ref } from 'vue'
import { getProductByEn, getUserDetail, postAuthSendSmsQuery, postPayCheck, postPayPayment, postQueryService, postUploadImage } from '@/api' import { getProductByEn, getUserDetail, postAuthSendSmsQuery, postPayCheck, postPayPayment, postQueryService, postUploadImage } from '@/api'
import { invokeWxVirtualPayment, prepareNativePayUI } from '@/utils/xpayPay' import { formatXpayPayError, invokeWxVirtualPayment, logXpayPayFailure, prepareNativePayUI, reportXpayClientEvent } from '@/utils/xpayPay'
import { productHasSmsCode, useInquireForm } from '@/composables/useInquireForm' import { productHasSmsCode, useInquireForm } from '@/composables/useInquireForm'
import { useBindMobile } from '@/composables/useBindMobile' import { useBindMobile } from '@/composables/useBindMobile'
import { aesEncrypt, QUERY_PAYLOAD_AES_HEX_KEY } from '@/utils/crypto.js' import { aesEncrypt, QUERY_PAYLOAD_AES_HEX_KEY } from '@/utils/crypto.js'
@@ -488,6 +488,12 @@ async function onConfirmPay() {
} }
if (payRes?.code !== 200 || !payRes.data) { if (payRes?.code !== 200 || !payRes.data) {
logXpayPayFailure('prepay', {
errMsg: payRes?.msg || '预下单失败',
errCode: payRes?.code,
}, { payBody, response: payRes })
if (payRes?.msg)
uni.showToast({ title: payRes.msg, icon: 'none', duration: 3000 })
return return
} }
@@ -532,21 +538,61 @@ async function onConfirmPay() {
prepareNativePayUI() prepareNativePayUI()
await new Promise(r => setTimeout(r, 120)) await new Promise(r => setTimeout(r, 120))
await invokeWxVirtualPayment(prepay) await reportXpayClientEvent({
orderNo,
stage: 'prepay_ok',
eventType: 'info',
message: '后端预下单成功,已拿到 xpay 签名参数',
extra: { order_no: orderNo },
})
await invokeWxVirtualPayment(prepay, { orderNo })
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
const checkRes = await postPayCheck({ order_no: orderNo }, { skipLoading: true }) const checkRes = await postPayCheck({ order_no: orderNo }, { skipLoading: true })
const status = checkRes?.data?.status const status = checkRes?.data?.status
const wxSyncError = checkRes?.data?.wx_sync_error
const wxOrderStatus = checkRes?.data?.wx_order_status
const wxOrderDetail = checkRes?.data?.wx_order_detail
console.info('[xpay:check]', {
orderNo,
round: i + 1,
status,
code: checkRes?.code,
wxSyncError,
wxOrderStatus,
wxOrderDetail,
})
if (wxSyncError) {
console.warn('[xpay:check] 后端 query_order 微信返回', wxSyncError)
await reportXpayClientEvent({
orderNo,
stage: 'check',
eventType: 'tip',
message: wxSyncError,
extra: { wx_order_status: wxOrderStatus, wx_order_detail: wxOrderDetail, round: i + 1 },
})
}
if (status === 'paid') { if (status === 'paid') {
afterPaySuccess() afterPaySuccess()
return return
} }
if (status === 'closed' || status === 'failed') { if (status === 'closed' || status === 'failed') {
logXpayPayFailure('check', {
errMsg: `轮询到账失败 status=${status}`,
errCode: checkRes?.code,
}, { orderNo, checkRes, round: i + 1 })
uni.showToast({ title: '订单已关闭', icon: 'none' }) uni.showToast({ title: '订单已关闭', icon: 'none' })
return return
} }
if (checkRes?.code !== 200 && checkRes?.msg) {
console.warn('[xpay:check] 业务异常', { orderNo, checkRes })
}
await new Promise(r => setTimeout(r, 1500)) await new Promise(r => setTimeout(r, 1500))
} }
logXpayPayFailure('check_timeout', {
errMsg: '支付后轮询 8 次仍未到账,请稍后在报告中查看或联系客服',
}, { orderNo })
uni.showToast({ title: '确认中,请稍后在报告中查看', icon: 'none' }) uni.showToast({ title: '确认中,请稍后在报告中查看', icon: 'none' })
return return
} }
@@ -575,9 +621,16 @@ async function onConfirmPay() {
}) })
} }
catch (e) { catch (e) {
const msg = e?.errMsg || e?.message || '' const formatted = e?.formatted || formatXpayPayError(e, { stage: 'pay' })
if (msg && !/cancel/i.test(msg)) if (!formatted.isCancel) {
uni.showToast({ title: String(msg).replace('requestVirtualPayment:fail ', ''), icon: 'none' }) if (!e?.formatted)
logXpayPayFailure('pay', e, { stage: 'pay' })
uni.showToast({
title: formatted.display.slice(0, 80),
icon: 'none',
duration: 4000,
})
}
} }
finally { finally {
paying.value = false paying.value = false

10
src/utils/loading.js Normal file
View File

@@ -0,0 +1,10 @@
/**
* 安全关闭 loading。
* 微信小程序在无 loading 时调用 hideLoading 会以 Promise reject 报
* hideLoading:fail toast can't be found须用 fail 回调吞掉。
*/
export function safeHideLoading() {
uni.hideLoading({
fail() {},
})
}

View File

@@ -1,23 +1,266 @@
import { safeHideLoading } from '@/utils/loading'
import { postXpayClientEvent } from '@/api/pay'
const reportConfig = { skipLoading: true, skipBizToast: true }
/** 微信 requestVirtualPayment 常见 errCode → 排查提示(含 iOS */
const XPAY_ERR_HINTS = {
'-1': '支付失败,请稍后重试',
'-2': '用户取消支付',
'-4': '风控拦截,请联系客服或更换账号/设备重试',
'-15001': '参数错误,详见 errMsg如道具未发布、金额不符、iOS 未开通等)',
'-15002': '商户单号重复,请重新发起查询再支付',
'-15003': '微信系统错误,多为 signData/env/道具配置不匹配',
'-15005': '用户态签名 signature 错误,请重新登录小程序后再试',
'-15006': '支付签名 paySig 错误,检查后端 Env 与 AppKey 是否配套',
'-15007': 'session_key 过期,请关闭小程序重新进入',
'-15010': '道具 productId 未在现网发布或未生效',
'-15011': 'env 必须为 0现网iOS 不支持沙箱 env=1',
'-15013': '道具价格 goodsPrice 与后台配置不一致',
'-15014': '道具/代币刚发布,约 10 分钟后生效',
'-15016': 'signData 格式错误',
'-15017': '商户涉嫌违规,收款受限(查微信商户平台)',
'-15019': '商户受限,查微信商户平台/商家助手',
'-15020': '操作过快,请稍后再试',
'701001': '当前设备/微信版本不支持 iOS 虚拟支付(需 iOS15+、微信 8.0.68+',
'701002': '需先完成微信支付实名认证',
}
/** 将 wx fail 对象完整序列化(含 errno/errCode 等不可枚举字段) */
function serializeWxPayErr(err) {
if (!err || typeof err !== 'object')
return err
const o = {}
for (const k of Object.keys(err))
o[k] = err[k]
if (err.errMsg != null)
o.errMsg = err.errMsg
if (err.errCode != null)
o.errCode = err.errCode
if (err.errno != null)
o.errno = err.errno
try {
return JSON.stringify(o)
}
catch {
return String(err)
}
}
/**
* 读取设备与微信环境(便于区分 iOS / Android
*/
export function getXpayDeviceContext() {
try {
const info = uni.getSystemInfoSync?.() || wx?.getSystemInfoSync?.() || {}
const account = wx?.getAccountInfoSync?.() || {}
return {
platform: info.platform || '',
system: info.system || '',
version: info.version || '',
SDKVersion: info.SDKVersion || '',
model: info.model || '',
envVersion: account.miniProgram?.envVersion || '',
}
}
catch {
return {}
}
}
function parseSignDataSummary(signData) {
if (!signData || typeof signData !== 'string')
return null
try {
const o = JSON.parse(signData)
return {
env: o.env,
offerId: o.offerId,
productId: o.productId,
goodsPrice: o.goodsPrice,
outTradeNo: o.outTradeNo,
}
}
catch {
return { raw: signData.slice(0, 120) }
}
}
/**
* 格式化虚拟支付失败信息(控制台 + Toast 文案)
* @param {Record<string, unknown>} err wx fail 回调或 Error
* @param {{ stage?: string, orderNo?: string, prepay?: object }} [ctx]
*/
export function formatXpayPayError(err, ctx = {}) {
const errMsg = String(err?.errMsg || err?.message || err?.msg || '未知错误')
const errCode = err?.errCode ?? err?.errno ?? err?.code
const codeKey = errCode != null ? String(errCode) : ''
const tail = errMsg.replace(/^requestVirtualPayment:fail\s*/i, '').trim()
const hint = XPAY_ERR_HINTS[codeKey] || ''
const device = getXpayDeviceContext()
const isIOS = device.platform === 'ios' || /ios/i.test(device.system || '')
const signSummary = ctx.prepay?.signData
? parseSignDataSummary(ctx.prepay.signData)
: null
let iosNote = ''
if (isIOS) {
if (signSummary?.env === 1)
iosNote = '【iOS】signData.env=1 沙箱iOS 必须用 env=0 + 现网 AppKey'
else if (/App Store|暂无法完成充值|Apple/i.test(errMsg))
iosNote = '【iOS/Apple】此为 Apple IAP 侧报错非后端接口常见原因Apple 服务波动、账号地区/支付方式、沙箱 env、MP 未开 iOS 支付'
else if (/尚未开启|701001|not support/i.test(errMsg))
iosNote = '【iOS】请在 MP 后台「虚拟支付」开通 Apple/IAP 支付,并配置小程序简称'
else if (signSummary?.goodsPrice != null && signSummary.goodsPrice < 100)
iosNote = '【iOS】最低支付金额 1 元100 分),当前 goodsPrice 可能过低'
}
const parts = [tail || errMsg]
if (codeKey)
parts.push(`[${codeKey}]`)
if (hint)
parts.push(hint)
if (iosNote)
parts.push(iosNote)
return {
display: parts.join(' '),
errMsg,
errCode: codeKey,
hint,
iosNote,
isIOS,
isCancel: /cancel/i.test(errMsg),
device,
signSummary,
stage: ctx.stage || '',
orderNo: ctx.orderNo || '',
}
}
/**
* 打印完整 xpay 失败日志(开发者工具 Console / 真机调试 vConsole
*/
export function logXpayPayFailure(stage, err, extra = {}) {
const formatted = formatXpayPayError(err, extra)
const tag = `[xpay:${stage}]`
console.error(tag, {
...formatted,
wxErrSerialized: serializeWxPayErr(err?.raw || err),
raw: err,
...extra,
})
return formatted
}
/** 从 wx fail 对象提取用户可见提示(含 App Store 话术,非 HTTP 异常) */
function extractWxClientMessage(err) {
const errMsg = String(err?.errMsg || err?.message || '')
return errMsg.replace(/^requestVirtualPayment:fail\s*/i, '').trim() || errMsg
}
function classifyWxClientEvent(err) {
const message = extractWxClientMessage(err)
if (/cancel/i.test(message))
return { event_type: 'info', message }
if (/App Store|暂无法完成充值|Apple/i.test(message))
return { event_type: 'tip', message }
return { event_type: 'fail', message }
}
/**
* 上报虚拟支付步骤到后端(写入服务端 [xpay:client-event] 日志fail/tip 时后端会 query_order
*/
export async function reportXpayClientEvent(payload) {
try {
const body = {
order_no: payload.orderNo || '',
stage: payload.stage || 'unknown',
event_type: payload.eventType || 'info',
message: payload.message || '',
err_msg: payload.errMsg || '',
err_code: payload.errCode != null ? Number(payload.errCode) : undefined,
errno: payload.errno != null ? Number(payload.errno) : undefined,
sign_data: payload.signData ? JSON.stringify(payload.signData) : '',
device: payload.device ? JSON.stringify(payload.device) : JSON.stringify(getXpayDeviceContext()),
extra: payload.extra ? JSON.stringify(payload.extra) : '',
}
const res = await postXpayClientEvent(body, reportConfig)
const data = res?.data
console.info('[xpay:server-log]', { request: body, response: data })
return data
}
catch (e) {
console.warn('[xpay:server-log] 上报失败', e)
return null
}
}
/** /**
* 调起微信小程序虚拟支付xpay * 调起微信小程序虚拟支付xpay
* @param {{ mode: string, signData: string, paySig: string, signature: string }} prepay * @param {{ mode: string, signData: string, paySig: string, signature: string, provider?: string }} prepay
* @param {{ orderNo?: string }} [options]
*/ */
export function invokeWxVirtualPayment(prepay) { export function invokeWxVirtualPayment(prepay, options = {}) {
const orderNo = options.orderNo || ''
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
if (typeof wx?.requestVirtualPayment !== 'function') { if (typeof wx?.requestVirtualPayment !== 'function') {
reject(new Error('当前环境不支持虚拟支付,请使用微信真机预览')) const e = { errMsg: '当前环境不支持虚拟支付,请使用微信真机预览', errCode: -1 }
logXpayPayFailure('unsupported', e, { orderNo, prepay: { provider: prepay?.provider } })
reject(e)
return return
} }
const device = getXpayDeviceContext()
const signSummary = parseSignDataSummary(prepay.signData)
console.info('[xpay:invoke]', { orderNo, mode: prepay.mode, device, signData: signSummary })
reportXpayClientEvent({
orderNo,
stage: 'invoke',
eventType: 'info',
message: '即将调起 wx.requestVirtualPayment',
signData: signSummary,
device,
})
wx.requestVirtualPayment({ wx.requestVirtualPayment({
mode: prepay.mode, mode: prepay.mode,
signData: prepay.signData, signData: prepay.signData,
paySig: prepay.paySig, paySig: prepay.paySig,
signature: prepay.signature, signature: prepay.signature,
success: resolve, success(res) {
fail: reject, console.info('[xpay:success]', { orderNo, res })
reportXpayClientEvent({
orderNo,
stage: 'success',
eventType: 'success',
message: 'wx.requestVirtualPayment success',
signData: signSummary,
device,
extra: res,
})
resolve(res)
},
fail(err) {
const formatted = logXpayPayFailure('requestVirtualPayment', err, { orderNo, prepay })
const classified = classifyWxClientEvent(err)
reportXpayClientEvent({
orderNo,
stage: 'fail',
eventType: classified.event_type,
message: classified.message,
errMsg: err?.errMsg,
errCode: err?.errCode,
errno: err?.errno,
signData: signSummary,
device,
extra: { formatted, raw: serializeWxPayErr(err) },
})
reject({ ...err, formatted })
},
}) })
console.log('invokeWxVirtualPayment', prepay)
// #endif // #endif
// #ifndef MP-WEIXIN // #ifndef MP-WEIXIN
reject(new Error('仅支持微信小程序虚拟支付')) reject(new Error('仅支持微信小程序虚拟支付'))
@@ -27,10 +270,5 @@ export function invokeWxVirtualPayment(prepay) {
/** 关闭可能遮挡原生支付弹窗的 loading / 自定义蒙层 */ /** 关闭可能遮挡原生支付弹窗的 loading / 自定义蒙层 */
export function prepareNativePayUI() { export function prepareNativePayUI() {
try { safeHideLoading()
uni.hideLoading()
}
catch {
/* ignore */
}
} }