fadd
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import un from '@uni-helper/uni-network'
|
||||
import { QNC_API } from '@/config/api'
|
||||
import { safeHideLoading } from '@/utils/loading'
|
||||
|
||||
/** 未单独配置登录页时,401 回到登录页 */
|
||||
const AUTH_FALLBACK_PAGE = '/pages/login'
|
||||
@@ -18,12 +19,7 @@ function showRequestLoading() {
|
||||
function hideRequestLoading() {
|
||||
if (--loadingCount <= 0) {
|
||||
loadingCount = 0
|
||||
try {
|
||||
uni.hideLoading()
|
||||
}
|
||||
catch {
|
||||
// 微信小程序真机调试时,如果没有 loading 显示会报错 "toast can't be found",忽略即可
|
||||
}
|
||||
safeHideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,3 +11,11 @@ export async function postPayCheck(body, requestConfig) {
|
||||
const res = await http.post('/pay/check', body, requestConfig)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { safeHideLoading } from '@/utils/loading'
|
||||
import { fetchReportShareUrl } from '@/utils/reportH5Link'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -23,7 +24,7 @@ async function copyLink() {
|
||||
orderId: props.orderId,
|
||||
orderNo: props.orderNo,
|
||||
})
|
||||
uni.hideLoading()
|
||||
safeHideLoading()
|
||||
await new Promise((resolve, reject) => {
|
||||
uni.setClipboardData({
|
||||
data: url,
|
||||
@@ -34,7 +35,7 @@ async function copyLink() {
|
||||
uni.showToast({ title: '链接已复制,可在浏览器打开', icon: 'none', duration: 2500 })
|
||||
}
|
||||
catch (e) {
|
||||
uni.hideLoading()
|
||||
safeHideLoading()
|
||||
uni.showToast({
|
||||
title: e?.message || '复制失败',
|
||||
icon: 'none',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onLoad, onReady } from '@dcloudio/uni-app'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
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 { useBindMobile } from '@/composables/useBindMobile'
|
||||
import { aesEncrypt, QUERY_PAYLOAD_AES_HEX_KEY } from '@/utils/crypto.js'
|
||||
@@ -488,6 +488,12 @@ async function onConfirmPay() {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -532,21 +538,61 @@ async function onConfirmPay() {
|
||||
prepareNativePayUI()
|
||||
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++) {
|
||||
const checkRes = await postPayCheck({ order_no: orderNo }, { skipLoading: true })
|
||||
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') {
|
||||
afterPaySuccess()
|
||||
return
|
||||
}
|
||||
if (status === 'closed' || status === 'failed') {
|
||||
logXpayPayFailure('check', {
|
||||
errMsg: `轮询到账失败 status=${status}`,
|
||||
errCode: checkRes?.code,
|
||||
}, { orderNo, checkRes, round: i + 1 })
|
||||
uni.showToast({ title: '订单已关闭', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (checkRes?.code !== 200 && checkRes?.msg) {
|
||||
console.warn('[xpay:check] 业务异常', { orderNo, checkRes })
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1500))
|
||||
}
|
||||
logXpayPayFailure('check_timeout', {
|
||||
errMsg: '支付后轮询 8 次仍未到账,请稍后在报告中查看或联系客服',
|
||||
}, { orderNo })
|
||||
uni.showToast({ title: '确认中,请稍后在报告中查看', icon: 'none' })
|
||||
return
|
||||
}
|
||||
@@ -575,9 +621,16 @@ async function onConfirmPay() {
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
const msg = e?.errMsg || e?.message || ''
|
||||
if (msg && !/cancel/i.test(msg))
|
||||
uni.showToast({ title: String(msg).replace('requestVirtualPayment:fail ', ''), icon: 'none' })
|
||||
const formatted = e?.formatted || formatXpayPayError(e, { stage: 'pay' })
|
||||
if (!formatted.isCancel) {
|
||||
if (!e?.formatted)
|
||||
logXpayPayFailure('pay', e, { stage: 'pay' })
|
||||
uni.showToast({
|
||||
title: formatted.display.slice(0, 80),
|
||||
icon: 'none',
|
||||
duration: 4000,
|
||||
})
|
||||
}
|
||||
}
|
||||
finally {
|
||||
paying.value = false
|
||||
|
||||
10
src/utils/loading.js
Normal file
10
src/utils/loading.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 安全关闭 loading。
|
||||
* 微信小程序在无 loading 时调用 hideLoading 会以 Promise reject 报
|
||||
* hideLoading:fail toast can't be found,须用 fail 回调吞掉。
|
||||
*/
|
||||
export function safeHideLoading() {
|
||||
uni.hideLoading({
|
||||
fail() {},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
* @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) => {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (typeof wx?.requestVirtualPayment !== 'function') {
|
||||
reject(new Error('当前环境不支持虚拟支付,请使用微信真机预览'))
|
||||
const e = { errMsg: '当前环境不支持虚拟支付,请使用微信真机预览', errCode: -1 }
|
||||
logXpayPayFailure('unsupported', e, { orderNo, prepay: { provider: prepay?.provider } })
|
||||
reject(e)
|
||||
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({
|
||||
mode: prepay.mode,
|
||||
signData: prepay.signData,
|
||||
paySig: prepay.paySig,
|
||||
signature: prepay.signature,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
success(res) {
|
||||
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
|
||||
// #ifndef MP-WEIXIN
|
||||
reject(new Error('仅支持微信小程序虚拟支付'))
|
||||
@@ -27,10 +270,5 @@ export function invokeWxVirtualPayment(prepay) {
|
||||
|
||||
/** 关闭可能遮挡原生支付弹窗的 loading / 自定义蒙层 */
|
||||
export function prepareNativePayUI() {
|
||||
try {
|
||||
uni.hideLoading()
|
||||
}
|
||||
catch {
|
||||
/* ignore */
|
||||
}
|
||||
safeHideLoading()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user