fix(frontend): stabilize wechat payment resume recovery

This commit is contained in:
IanShaw027
2026-04-22 12:30:24 +08:00
parent d6a04bb772
commit 29caf85104
6 changed files with 304 additions and 30 deletions

View File

@@ -181,6 +181,54 @@ function isPendingStatus(status: string | null | undefined): boolean {
return PENDING_STATUSES.has(normalizeOrderStatus(status)) return PENDING_STATUSES.has(normalizeOrderStatus(status))
} }
function readRouteQueryString(key: string): string {
const value = route.query[key]
if (Array.isArray(value)) {
return typeof value[0] === 'string' ? value[0] : ''
}
return typeof value === 'string' ? value : ''
}
function restoreRecoverySnapshot(context: {
resumeToken: string
routeOrderId: number
routeOutTradeNo: string
}) {
if (typeof window === 'undefined') {
return null
}
const rawSnapshot = window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)
if (!rawSnapshot) {
return null
}
if (context.resumeToken) {
return readPaymentRecoverySnapshot(rawSnapshot, {
resumeToken: context.resumeToken,
})
}
if (!context.routeOrderId && !context.routeOutTradeNo) {
return null
}
const restored = readPaymentRecoverySnapshot(rawSnapshot)
if (!restored) {
return null
}
if (context.routeOrderId > 0 && restored.orderId !== context.routeOrderId) {
return null
}
if (context.routeOutTradeNo && restored.outTradeNo !== context.routeOutTradeNo) {
return null
}
return restored
}
async function resolveOrderFromResumeToken(resumeToken: string): Promise<PaymentOrder | null> { async function resolveOrderFromResumeToken(resumeToken: string): Promise<PaymentOrder | null> {
try { try {
const result = await paymentAPI.resolveOrderPublicByResumeToken(resumeToken) const result = await paymentAPI.resolveOrderPublicByResumeToken(resumeToken)
@@ -239,24 +287,21 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>
} }
onMounted(async () => { onMounted(async () => {
const resumeToken = typeof route.query.resume_token === 'string' const resumeToken = readRouteQueryString('resume_token')
? route.query.resume_token const routeOrderId = Number(readRouteQueryString('order_id')) || 0
: '' let outTradeNo = readRouteQueryString('out_trade_no')
const routeOrderId = Number(route.query.order_id) || 0
let outTradeNo = String(route.query.out_trade_no || '')
let orderId = 0 let orderId = 0
if (typeof window !== 'undefined') { const restored = restoreRecoverySnapshot({
const restored = readPaymentRecoverySnapshot( resumeToken,
window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY), routeOrderId,
resumeToken ? { resumeToken } : {}, routeOutTradeNo: outTradeNo,
) })
if (restored?.orderId) { if (restored?.orderId) {
orderId = restored.orderId orderId = restored.orderId
} }
if (!outTradeNo && restored?.outTradeNo) { if (!outTradeNo && restored?.outTradeNo) {
outTradeNo = restored.outTradeNo outTradeNo = restored.outTradeNo
}
} }
if (resumeToken) { if (resumeToken) {
@@ -266,15 +311,14 @@ onMounted(async () => {
if (!orderId) { if (!orderId) {
orderId = resolvedOrder.id orderId = resolvedOrder.id
} }
} else if (routeOrderId > 0) {
orderId = routeOrderId
} }
} } else if (routeOrderId > 0) {
if (!resumeToken) {
orderId = routeOrderId orderId = routeOrderId
} }
const hasLegacyFallbackContext = typeof route.query.trade_status === 'string' const hasLegacyFallbackContext = readRouteQueryString('trade_status').trim() !== ''
&& route.query.trade_status.trim() !== ''
const shouldUsePublicOutTradeNo = !resumeToken && outTradeNo !== '' && (hasLegacyFallbackContext || routeOrderId > 0 || orderId > 0) const shouldUsePublicOutTradeNo = !resumeToken && outTradeNo !== '' && (hasLegacyFallbackContext || routeOrderId > 0 || orderId > 0)
if (!order.value && shouldUsePublicOutTradeNo) { if (!order.value && shouldUsePublicOutTradeNo) {
@@ -287,7 +331,7 @@ onMounted(async () => {
} }
} }
if (!order.value && !resumeToken && orderId) { if (!order.value && orderId && (!resumeToken || routeOrderId > 0)) {
try { try {
order.value = await paymentStore.pollOrderStatus(orderId) order.value = await paymentStore.pollOrderStatus(orderId)
} catch (_err: unknown) { } catch (_err: unknown) {

View File

@@ -409,6 +409,43 @@ async function redirectToPaymentResult(state: PaymentRecoverySnapshot): Promise<
}) })
} }
function buildWechatOAuthAuthorizeUrl(
authorizeUrl: string,
context: { paymentType: string; orderType: OrderType; planId?: number; orderAmount: number },
): string {
const normalizedUrl = authorizeUrl.trim()
if (!normalizedUrl || typeof window === 'undefined') {
return normalizedUrl
}
try {
const targetUrl = new URL(normalizedUrl, window.location.origin)
const redirectPath = targetUrl.searchParams.get('redirect') || '/purchase'
const redirectUrl = new URL(redirectPath, window.location.origin)
const paymentType = normalizeVisibleMethod(context.paymentType) || context.paymentType.trim() || 'wxpay'
redirectUrl.searchParams.set('payment_type', paymentType)
redirectUrl.searchParams.set('order_type', context.orderType)
if (context.planId) {
redirectUrl.searchParams.set('plan_id', String(context.planId))
} else {
redirectUrl.searchParams.delete('plan_id')
}
if (context.orderAmount > 0) {
redirectUrl.searchParams.set('amount', String(context.orderAmount))
} else {
redirectUrl.searchParams.delete('amount')
}
targetUrl.searchParams.set('redirect', `${redirectUrl.pathname}${redirectUrl.search}`)
return targetUrl.toString()
} catch {
return normalizedUrl
}
}
function onPaymentDone() { function onPaymentDone() {
const wasSubscription = paymentState.value.orderType === 'subscription' const wasSubscription = paymentState.value.orderType === 'subscription'
resetPayment() resetPayment()
@@ -676,7 +713,12 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
}) })
if (decision.kind === 'wechat_oauth' && decision.oauth?.authorize_url) { if (decision.kind === 'wechat_oauth' && decision.oauth?.authorize_url) {
window.location.href = decision.oauth.authorize_url window.location.href = buildWechatOAuthAuthorizeUrl(decision.oauth.authorize_url, {
paymentType: visibleMethod,
orderType,
planId,
orderAmount,
})
return return
} }

View File

@@ -220,6 +220,41 @@ describe('PaymentResultView', () => {
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull() expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
}) })
it('falls back to order_id polling when resume-token recovery fails', async () => {
routeState.query = {
resume_token: 'resume-fail',
order_id: '77',
}
window.localStorage.setItem(
PAYMENT_RECOVERY_STORAGE_KEY,
JSON.stringify({
...recoverySnapshotFactory('resume-fail'),
orderId: 42,
}),
)
resolveOrderPublicByResumeToken.mockRejectedValueOnce(new Error('resume failed'))
pollOrderStatus.mockResolvedValueOnce({
...orderFactory('PAID'),
id: 77,
})
const wrapper = mount(PaymentResultView, {
global: {
stubs: {
OrderStatusBadge: true,
},
},
})
await flushPromises()
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-fail')
expect(pollOrderStatus).toHaveBeenCalledWith(77)
expect(verifyOrderPublic).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('payment.result.success')
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('does not fall back to public out_trade_no verification when resume_token recovery fails', async () => {
routeState.query = { routeState.query = {
resume_token: 'resume-fail', resume_token: 'resume-fail',
@@ -241,6 +276,32 @@ describe('PaymentResultView', () => {
expect(verifyOrderPublic).not.toHaveBeenCalled() expect(verifyOrderPublic).not.toHaveBeenCalled()
}) })
it('ignores a stale global recovery snapshot when legacy return markers do not identify the order', async () => {
routeState.query = {
trade_status: 'TRADE_SUCCESS',
}
window.localStorage.setItem(
PAYMENT_RECOVERY_STORAGE_KEY,
JSON.stringify(recoverySnapshotFactory('resume-stale')),
)
const wrapper = mount(PaymentResultView, {
global: {
stubs: {
OrderStatusBadge: true,
},
},
})
await flushPromises()
expect(resolveOrderPublicByResumeToken).not.toHaveBeenCalled()
expect(verifyOrderPublic).not.toHaveBeenCalled()
expect(pollOrderStatus).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('payment.result.failed')
expect(wrapper.text()).not.toContain('sub2_20260420abcd1234')
})
it('uses public out_trade_no verification when no signed resume context is available', async () => { it('uses public out_trade_no verification when no signed resume context is available', async () => {
routeState.query = { routeState.query = {
out_trade_no: 'legacy-123', out_trade_no: 'legacy-123',

View File

@@ -109,6 +109,35 @@ function checkoutInfoFixture() {
} }
} }
function checkoutInfoWithPlansFixture() {
return {
data: {
...checkoutInfoFixture().data,
plans: [
{
id: 7,
group_id: 3,
name: 'Starter',
description: '',
price: 128,
original_price: 0,
validity_days: 30,
validity_unit: 'day',
rate_multiplier: 1,
daily_limit_usd: null,
weekly_limit_usd: null,
monthly_limit_usd: null,
features: [],
group_platform: 'openai',
sort_order: 1,
for_sale: true,
group_name: 'OpenAI',
},
],
},
}
}
function jsapiOrderFixture(resumeToken: string) { function jsapiOrderFixture(resumeToken: string) {
return { return {
order_id: 123, order_id: 123,
@@ -131,6 +160,24 @@ function jsapiOrderFixture(resumeToken: string) {
} }
} }
function oauthOrderFixture() {
return {
order_id: 456,
amount: 128,
pay_amount: 128,
fee_rate: 0,
expires_at: '2099-01-01T00:10:00.000Z',
payment_type: 'wxpay',
result_type: 'oauth_required' as const,
oauth: {
authorize_url: '/api/v1/auth/oauth/wechat/payment/start?payment_type=wxpay&redirect=%2Fpurchase%3Ffrom%3Dwechat',
appid: 'wx123',
scope: 'snsapi_base',
redirect_url: '/auth/wechat/payment/callback',
},
}
}
describe('PaymentView WeChat JSAPI flow', () => { describe('PaymentView WeChat JSAPI flow', () => {
beforeEach(() => { beforeEach(() => {
routeState.path = '/purchase' routeState.path = '/purchase'
@@ -239,4 +286,78 @@ describe('PaymentView WeChat JSAPI flow', () => {
})) }))
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull() expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
}) })
it('keeps subscription resume context for token-only WeChat callbacks', async () => {
routeState.query = {
wechat_resume: '1',
wechat_resume_token: 'resume-subscription-7',
payment_type: 'wxpay_direct',
order_type: 'subscription',
plan_id: '7',
}
getCheckoutInfo.mockResolvedValue(checkoutInfoWithPlansFixture())
createOrder.mockResolvedValue(oauthOrderFixture())
const originalLocation = window.location
const locationState = {
href: 'http://localhost/purchase',
origin: 'http://localhost',
}
Object.defineProperty(window, 'location', {
configurable: true,
value: locationState,
})
shallowMount(PaymentView, {
global: {
stubs: {
Teleport: true,
Transition: false,
},
},
})
await flushPromises()
await flushPromises()
expect(routerReplace).toHaveBeenCalledWith({ path: '/purchase', query: {} })
expect(createOrder).toHaveBeenCalledWith(expect.objectContaining({
payment_type: 'wxpay',
order_type: 'subscription',
plan_id: 7,
wechat_resume_token: 'resume-subscription-7',
}))
expect(locationState.href).toContain('/api/v1/auth/oauth/wechat/payment/start?')
expect(new URL(locationState.href, 'http://localhost').searchParams.get('redirect')).toBe(
'/purchase?from=wechat&payment_type=wxpay&order_type=subscription&plan_id=7',
)
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
})
})
it('shows explicit H5 authorization guidance instead of failing silently', async () => {
routeState.query = {
wechat_resume: '1',
wechat_resume_token: 'resume-token-h5',
payment_type: 'wxpay_direct',
}
createOrder.mockRejectedValueOnce({ reason: 'WECHAT_H5_NOT_AUTHORIZED' })
shallowMount(PaymentView, {
global: {
stubs: {
Teleport: true,
Transition: false,
},
},
})
await flushPromises()
await flushPromises()
expect(showError).toHaveBeenCalledWith(
'payment.errors.wechatH5NotAuthorized payment.errors.wechatOpenInWeChatHint',
)
})
}) })

View File

@@ -14,8 +14,9 @@ describe('parseWechatResumeRoute', () => {
}, [], 88)).toEqual({ }, [], 88)).toEqual({
wechatResumeToken: 'resume-token-123', wechatResumeToken: 'resume-token-123',
paymentType: 'wxpay', paymentType: 'wxpay',
orderType: 'balance', orderType: 'subscription',
orderAmount: 0, orderAmount: 0,
planId: 7,
}) })
}) })

View File

@@ -37,12 +37,20 @@ export function parseWechatResumeRoute(
} }
const wechatResumeToken = readQueryString(query, 'wechat_resume_token') const wechatResumeToken = readQueryString(query, 'wechat_resume_token')
const paymentType = normalizeVisibleMethod(readQueryString(query, 'payment_type')) || 'wxpay'
const planId = Number.parseInt(readQueryString(query, 'plan_id'), 10)
const hasPlanId = Number.isFinite(planId) && planId > 0
const orderType = readQueryString(query, 'order_type') === 'subscription' || hasPlanId
? 'subscription'
: 'balance'
if (wechatResumeToken) { if (wechatResumeToken) {
return { return {
wechatResumeToken, wechatResumeToken,
paymentType: 'wxpay', paymentType,
orderType: 'balance', orderType,
orderAmount: 0, orderAmount: 0,
planId: hasPlanId ? planId : undefined,
} }
} }
@@ -51,9 +59,6 @@ export function parseWechatResumeRoute(
return null return null
} }
const paymentType = normalizeVisibleMethod(readQueryString(query, 'payment_type')) || 'wxpay'
const orderType = readQueryString(query, 'order_type') === 'subscription' ? 'subscription' : 'balance'
const planId = Number.parseInt(readQueryString(query, 'plan_id'), 10)
const rawAmount = Number.parseFloat(readQueryString(query, 'amount')) const rawAmount = Number.parseFloat(readQueryString(query, 'amount'))
const orderAmount = Number.isFinite(rawAmount) && rawAmount > 0 const orderAmount = Number.isFinite(rawAmount) && rawAmount > 0
? rawAmount ? rawAmount
@@ -66,7 +71,7 @@ export function parseWechatResumeRoute(
paymentType, paymentType,
orderType, orderType,
orderAmount, orderAmount,
planId: Number.isFinite(planId) && planId > 0 ? planId : undefined, planId: hasPlanId ? planId : undefined,
} }
} }