fix(frontend): stabilize wechat payment resume recovery
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,8 +14,9 @@ describe('parseWechatResumeRoute', () => {
|
||||
}, [], 88)).toEqual({
|
||||
wechatResumeToken: 'resume-token-123',
|
||||
paymentType: 'wxpay',
|
||||
orderType: 'balance',
|
||||
orderType: 'subscription',
|
||||
orderAmount: 0,
|
||||
planId: 7,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user