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))
|
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,25 +287,22 @@ 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) {
|
||||||
const resolvedOrder = await resolveOrderFromResumeToken(resumeToken)
|
const resolvedOrder = await resolveOrderFromResumeToken(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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user