diff --git a/frontend/src/views/user/PaymentResultView.vue b/frontend/src/views/user/PaymentResultView.vue index cbebaa83..b75d75df 100644 --- a/frontend/src/views/user/PaymentResultView.vue +++ b/frontend/src/views/user/PaymentResultView.vue @@ -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 { try { const result = await paymentAPI.resolveOrderPublicByResumeToken(resumeToken) @@ -239,24 +287,21 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise } 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) { diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 1577039e..05d70512 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -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 } diff --git a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts index 91741963..81a7ccf0 100644 --- a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts @@ -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', diff --git a/frontend/src/views/user/__tests__/PaymentView.spec.ts b/frontend/src/views/user/__tests__/PaymentView.spec.ts index 66648da4..2b81a085 100644 --- a/frontend/src/views/user/__tests__/PaymentView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentView.spec.ts @@ -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', + ) + }) }) diff --git a/frontend/src/views/user/__tests__/paymentWechatResume.spec.ts b/frontend/src/views/user/__tests__/paymentWechatResume.spec.ts index c850ec1b..85b2b0fd 100644 --- a/frontend/src/views/user/__tests__/paymentWechatResume.spec.ts +++ b/frontend/src/views/user/__tests__/paymentWechatResume.spec.ts @@ -14,8 +14,9 @@ describe('parseWechatResumeRoute', () => { }, [], 88)).toEqual({ wechatResumeToken: 'resume-token-123', paymentType: 'wxpay', - orderType: 'balance', + orderType: 'subscription', orderAmount: 0, + planId: 7, }) }) diff --git a/frontend/src/views/user/paymentWechatResume.ts b/frontend/src/views/user/paymentWechatResume.ts index 64f254da..8121bc56 100644 --- a/frontend/src/views/user/paymentWechatResume.ts +++ b/frontend/src/views/user/paymentWechatResume.ts @@ -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, } }