fix(payment): restore upgrade-safe payment flows

This commit is contained in:
IanShaw027
2026-04-22 14:57:16 +08:00
parent 36aed35957
commit 1aab084ecb
14 changed files with 645 additions and 68 deletions

View File

@@ -291,6 +291,7 @@ onMounted(async () => {
const routeOrderId = Number(readRouteQueryString('order_id')) || 0
let outTradeNo = readRouteQueryString('out_trade_no')
let orderId = 0
let resumeTokenLookupFailed = false
const restored = restoreRecoverySnapshot({
resumeToken,
@@ -312,24 +313,17 @@ onMounted(async () => {
orderId = resolvedOrder.id
}
} else if (routeOrderId > 0) {
resumeTokenLookupFailed = true
orderId = routeOrderId
} else {
resumeTokenLookupFailed = true
}
} else if (routeOrderId > 0) {
orderId = routeOrderId
}
const hasLegacyFallbackContext = readRouteQueryString('trade_status').trim() !== ''
const shouldUsePublicOutTradeNo = !resumeToken && outTradeNo !== '' && (hasLegacyFallbackContext || routeOrderId > 0 || orderId > 0)
if (!order.value && shouldUsePublicOutTradeNo) {
const legacyOrder = await resolveOrderFromOutTradeNo(outTradeNo)
if (legacyOrder) {
order.value = legacyOrder
if (!orderId) {
orderId = legacyOrder.id
}
}
}
const shouldUsePublicOutTradeNo = outTradeNo !== '' && (hasLegacyFallbackContext || routeOrderId > 0 || orderId > 0)
if (!order.value && orderId && (!resumeToken || routeOrderId > 0)) {
try {
@@ -339,7 +333,17 @@ onMounted(async () => {
}
}
if (!order.value && !resumeToken && !orderId && outTradeNo && hasLegacyFallbackContext) {
if (!order.value && shouldUsePublicOutTradeNo && (!resumeToken || resumeTokenLookupFailed)) {
const legacyOrder = await resolveOrderFromOutTradeNo(outTradeNo)
if (legacyOrder) {
order.value = legacyOrder
if (!orderId) {
orderId = legacyOrder.id
}
}
}
if (!order.value && !orderId && outTradeNo && hasLegacyFallbackContext) {
returnInfo.value = {
outTradeNo,
money: String(route.query.money || ''),
@@ -350,17 +354,24 @@ onMounted(async () => {
const refreshOrder = async (): Promise<PaymentOrder | null> => {
if (resumeToken) {
return await resolveOrderFromResumeToken(resumeToken)
const resolvedOrder = await resolveOrderFromResumeToken(resumeToken)
if (resolvedOrder) {
return resolvedOrder
}
}
if (orderId) {
try {
return await paymentStore.pollOrderStatus(orderId)
} catch (_err: unknown) {
// Fall through to legacy public verification when order polling is unavailable.
}
}
if (shouldUsePublicOutTradeNo) {
return await resolveOrderFromOutTradeNo(outTradeNo)
}
if (orderId) {
return await paymentStore.pollOrderStatus(orderId)
}
return null
}

View File

@@ -740,18 +740,23 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
return
}
if (decision.kind === 'wechat_jsapi' && decision.jsapi) {
const jsapiResult = await invokeWechatJsapiPayment(decision.jsapi as Record<string, unknown>)
const errMsg = String(jsapiResult.err_msg || '').toLowerCase()
if (errMsg.includes('cancel')) {
appStore.showInfo(t('payment.qr.cancelled'))
try {
const jsapiResult = await invokeWechatJsapiPayment(decision.jsapi as Record<string, unknown>)
const errMsg = String(jsapiResult.err_msg || '').toLowerCase()
if (errMsg.includes('cancel')) {
appStore.showInfo(t('payment.qr.cancelled'))
resetPayment()
} else if (errMsg && !errMsg.includes('ok')) {
applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod)
resetPayment()
} else {
const resultState = { ...decision.paymentState }
resetPayment()
await redirectToPaymentResult(resultState)
}
} catch (err: unknown) {
resetPayment()
} else if (errMsg && !errMsg.includes('ok')) {
applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod)
resetPayment()
} else {
const resultState = { ...decision.paymentState }
resetPayment()
await redirectToPaymentResult(resultState)
throw err
}
return
}

View File

@@ -255,14 +255,21 @@ describe('PaymentResultView', () => {
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
})
it('does not fall back to public out_trade_no verification when resume_token recovery fails', async () => {
it('falls back to public out_trade_no verification when resume_token recovery fails in legacy return flows', async () => {
routeState.query = {
resume_token: 'resume-fail',
out_trade_no: 'legacy-should-not-run',
trade_status: 'TRADE_SUCCESS',
}
resolveOrderPublicByResumeToken.mockRejectedValueOnce(new Error('resume failed'))
mount(PaymentResultView, {
verifyOrderPublic.mockResolvedValueOnce({
data: {
...orderFactory('PAID'),
out_trade_no: 'legacy-should-not-run',
},
})
const wrapper = mount(PaymentResultView, {
global: {
stubs: {
OrderStatusBadge: true,
@@ -273,7 +280,9 @@ describe('PaymentResultView', () => {
await flushPromises()
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-fail')
expect(verifyOrderPublic).not.toHaveBeenCalled()
expect(verifyOrderPublic).toHaveBeenCalledWith('legacy-should-not-run')
expect(pollOrderStatus).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('payment.result.success')
})
it('ignores a stale global recovery snapshot when legacy return markers do not identify the order', async () => {

View File

@@ -252,6 +252,33 @@ describe('PaymentView WeChat JSAPI flow', () => {
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
})
it('clears stale recovery state when JSAPI never becomes available', async () => {
vi.useFakeTimers()
createOrder.mockResolvedValue(jsapiOrderFixture('resume-token-missing-bridge'))
;(window as Window & { WeixinJSBridge?: { invoke: typeof bridgeInvoke } }).WeixinJSBridge = undefined
const wrapper = shallowMount(PaymentView, {
global: {
stubs: {
Teleport: true,
Transition: false,
},
},
})
await flushPromises()
await vi.advanceTimersByTimeAsync(4000)
await flushPromises()
await flushPromises()
expect(showError).toHaveBeenCalledWith(
'payment.errors.wechatJsapiUnavailable payment.errors.wechatOpenInWeChatHint',
)
expect(routerPush).not.toHaveBeenCalled()
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
expect(wrapper.html()).not.toContain('payment-status-panel-stub')
})
it('clears a stale recovery snapshot before handling wechat resume callback params', async () => {
createOrder.mockRejectedValueOnce(new Error('resume failed'))
window.localStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify({