From 1c996fe301d761beb1b443a383cfe3d76abcd87b Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Fri, 2 Oct 2026 22:21:02 +0800 Subject: [PATCH] fix(payment): stop late Stripe callbacks after leaving the page --- frontend/src/views/user/StripePaymentView.vue | 7 +- .../user/__tests__/StripePaymentView.spec.ts | 66 ++++++++++++++++++- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/frontend/src/views/user/StripePaymentView.vue b/frontend/src/views/user/StripePaymentView.vue index 840eea912a..b845cfac52 100644 --- a/frontend/src/views/user/StripePaymentView.vue +++ b/frontend/src/views/user/StripePaymentView.vue @@ -129,6 +129,7 @@ const wechatQrUrl = ref('') const redirecting = ref(false) const showPaymentElement = ref(false) +let disposed = false let stripeInstance: Stripe | null = null let elementsInstance: StripeElements | null = null let redirectTimer: ReturnType | null = null @@ -167,6 +168,7 @@ onMounted(async () => { const { loadStripe } = await import('@stripe/stripe-js/pure') const stripe = await loadStripe(publishableKey) + if (disposed) return if (!stripe) { initError.value = t('payment.stripeLoadFailed'); return } stripeInstance = stripe @@ -241,6 +243,7 @@ async function confirmWechatPay(stripe: Stripe, clientSecret: string) { } function mountPaymentElement(stripe: Stripe, clientSecret: string) { + if (disposed) return const isDark = document.documentElement.classList.contains('dark') const elements = stripe.elements({ clientSecret, @@ -284,7 +287,7 @@ let pollTimer: ReturnType | null = null function startPolling() { const orderId = Number(route.query.order_id) - if (!orderId) return + if (!orderId || disposed) return pollTimer = setInterval(async () => { const o = await paymentStore.pollOrderStatus(orderId) if (!o) return @@ -298,6 +301,7 @@ function startPolling() { } function scheduleClose() { + if (disposed) return if (window.opener) { redirectTimer = setTimeout(() => { window.close() }, 2000) } else { @@ -308,6 +312,7 @@ function scheduleClose() { } onUnmounted(() => { + disposed = true if (redirectTimer) clearTimeout(redirectTimer) if (pollTimer) clearInterval(pollTimer) }) diff --git a/frontend/src/views/user/__tests__/StripePaymentView.spec.ts b/frontend/src/views/user/__tests__/StripePaymentView.spec.ts index 9b672048b8..0d85126a3f 100644 --- a/frontend/src/views/user/__tests__/StripePaymentView.spec.ts +++ b/frontend/src/views/user/__tests__/StripePaymentView.spec.ts @@ -1,5 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { flushPromises, shallowMount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises, shallowMount } from '@vue/test-utils' + +enableAutoUnmount(afterEach) +afterEach(() => { vi.clearAllTimers(); vi.useRealTimers() }) const routeState = vi.hoisted(() => ({ query: {} as Record, @@ -101,7 +104,7 @@ describe('StripePaymentView', () => { client_secret: 'pi_secret_42', } routerPush.mockReset() - getOrder.mockReset() + getOrder.mockReset().mockResolvedValue({ data: orderFactory() }) paymentStore.config = { stripe_publishable_key: 'pk_test' } paymentStore.fetchConfig.mockReset().mockResolvedValue(undefined) paymentStore.pollOrderStatus.mockReset() @@ -118,6 +121,63 @@ describe('StripePaymentView', () => { window.localStorage.clear() }) + it.each(['alipay', 'wechat_pay', ''])('does not start %s payment after leaving during SDK loading', async (method) => { + routeState.query.method = method + let resolveSDK!: (value: typeof stripeInstance) => void + loadStripe.mockReturnValueOnce(new Promise((resolve) => { resolveSDK = resolve })) + const wrapper = mountView() + await flushPromises() + await flushPromises() + expect(loadStripe).toHaveBeenCalledTimes(1) + wrapper.unmount() + resolveSDK(stripeInstance) + await flushPromises() + await flushPromises() + expect(stripeInstance.confirmAlipayPayment).not.toHaveBeenCalled() + expect(stripeInstance.confirmWechatPayPayment).not.toHaveBeenCalled() + expect(stripeInstance.elements).not.toHaveBeenCalled() + }) + + it('does not restart polling when a WeChat QR result arrives after leaving', async () => { + vi.useFakeTimers() + routeState.query.method = 'wechat_pay' + let resolvePayment!: (value: unknown) => void + stripeInstance.confirmWechatPayPayment.mockReturnValueOnce(new Promise((resolve) => { resolvePayment = resolve })) + const wrapper = mountView() + await flushPromises() + await flushPromises() + expect(stripeInstance.confirmWechatPayPayment).toHaveBeenCalledTimes(1) + wrapper.unmount() + resolvePayment({ paymentIntent: { status: 'requires_action', next_action: { + wechat_pay_display_qr_code: { image_data_url: 'data:image/png;base64,test' } + } } }) + await flushPromises() + await vi.advanceTimersByTimeAsync(3000) + expect(paymentStore.pollOrderStatus).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it.each([false, true])('handles a completed poll with page unmounted=%s', async (unmounted) => { + vi.useFakeTimers() + routeState.query.method = 'wechat_pay' + stripeInstance.confirmWechatPayPayment.mockResolvedValueOnce({ paymentIntent: { + status: 'requires_action', next_action: { wechat_pay_display_qr_code: { image_data_url: 'data:image/png;base64,test' } } + } }) + let resolvePoll!: (value: PaymentOrder) => void + paymentStore.pollOrderStatus.mockReturnValueOnce(new Promise((resolve) => { resolvePoll = resolve })) + const wrapper = mountView() + await flushPromises() + await flushPromises() + await vi.advanceTimersByTimeAsync(3000) + expect(paymentStore.pollOrderStatus).toHaveBeenCalledTimes(1) + if (unmounted) wrapper.unmount() + resolvePoll(orderFactory({ status: 'PAID' })) + await flushPromises() + await vi.advanceTimersByTimeAsync(2000) + expect(routerPush).toHaveBeenCalledTimes(unmounted ? 0 : 1) + expect(vi.getTimerCount()).toBe(0) + }) + it('本地恢复快照缺失时使用订单接口返回的 Stripe 币种展示金额', async () => { getOrder.mockResolvedValue({ data: orderFactory({ currency: 'HKD', pay_amount: 103 }),