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))
}
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> {
try {
const result = await paymentAPI.resolveOrderPublicByResumeToken(resumeToken)
@@ -239,24 +287,21 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>
}
onMounted(async () => {
const resumeToken = typeof route.query.resume_token === 'string'
? route.query.resume_token
: ''
const routeOrderId = Number(route.query.order_id) || 0
let outTradeNo = String(route.query.out_trade_no || '')
const resumeToken = readRouteQueryString('resume_token')
const routeOrderId = Number(readRouteQueryString('order_id')) || 0
let outTradeNo = readRouteQueryString('out_trade_no')
let orderId = 0
if (typeof window !== 'undefined') {
const restored = readPaymentRecoverySnapshot(
window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY),
resumeToken ? { resumeToken } : {},
)
if (restored?.orderId) {
orderId = restored.orderId
}
if (!outTradeNo && restored?.outTradeNo) {
outTradeNo = restored.outTradeNo
}
const restored = restoreRecoverySnapshot({
resumeToken,
routeOrderId,
routeOutTradeNo: outTradeNo,
})
if (restored?.orderId) {
orderId = restored.orderId
}
if (!outTradeNo && restored?.outTradeNo) {
outTradeNo = restored.outTradeNo
}
if (resumeToken) {
@@ -266,15 +311,14 @@ onMounted(async () => {
if (!orderId) {
orderId = resolvedOrder.id
}
} else if (routeOrderId > 0) {
orderId = routeOrderId
}
}
if (!resumeToken) {
} else if (routeOrderId > 0) {
orderId = routeOrderId
}
const hasLegacyFallbackContext = typeof route.query.trade_status === 'string'
&& route.query.trade_status.trim() !== ''
const hasLegacyFallbackContext = readRouteQueryString('trade_status').trim() !== ''
const shouldUsePublicOutTradeNo = !resumeToken && outTradeNo !== '' && (hasLegacyFallbackContext || routeOrderId > 0 || orderId > 0)
if (!order.value && shouldUsePublicOutTradeNo) {
@@ -287,7 +331,7 @@ onMounted(async () => {
}
}
if (!order.value && !resumeToken && orderId) {
if (!order.value && orderId && (!resumeToken || routeOrderId > 0)) {
try {
order.value = await paymentStore.pollOrderStatus(orderId)
} 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() {
const wasSubscription = paymentState.value.orderType === 'subscription'
resetPayment()
@@ -676,7 +713,12 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
})
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
}

View File

@@ -220,6 +220,41 @@ describe('PaymentResultView', () => {
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 () => {
routeState.query = {
resume_token: 'resume-fail',
@@ -241,6 +276,32 @@ describe('PaymentResultView', () => {
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 () => {
routeState.query = {
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) {
return {
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', () => {
beforeEach(() => {
routeState.path = '/purchase'
@@ -239,4 +286,78 @@ describe('PaymentView WeChat JSAPI flow', () => {
}))
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({
wechatResumeToken: 'resume-token-123',
paymentType: 'wxpay',
orderType: 'balance',
orderType: 'subscription',
orderAmount: 0,
planId: 7,
})
})

View File

@@ -37,12 +37,20 @@ export function parseWechatResumeRoute(
}
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) {
return {
wechatResumeToken,
paymentType: 'wxpay',
orderType: 'balance',
paymentType,
orderType,
orderAmount: 0,
planId: hasPlanId ? planId : undefined,
}
}
@@ -51,9 +59,6 @@ export function parseWechatResumeRoute(
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 orderAmount = Number.isFinite(rawAmount) && rawAmount > 0
? rawAmount
@@ -66,7 +71,7 @@ export function parseWechatResumeRoute(
paymentType,
orderType,
orderAmount,
planId: Number.isFinite(planId) && planId > 0 ? planId : undefined,
planId: hasPlanId ? planId : undefined,
}
}