diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 4727e1602b..4e64af2efa 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -874,7 +874,6 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n path: '/payment/stripe', query: { order_id: String(result.order_id), - client_secret: result.client_secret, method: stripeMethod || undefined, resume_token: result.resume_token || undefined, }, @@ -1082,7 +1081,6 @@ async function attemptMobileQrFallback(err: unknown, context: MobileQrFallbackCo path: '/payment/stripe', query: { order_id: String(result.order_id), - client_secret: result.client_secret, method: stripeMethod, resume_token: result.resume_token || undefined, }, diff --git a/frontend/src/views/user/StripePaymentView.vue b/frontend/src/views/user/StripePaymentView.vue index b845cfac52..2e6a5bbbf7 100644 --- a/frontend/src/views/user/StripePaymentView.vue +++ b/frontend/src/views/user/StripePaymentView.vue @@ -136,9 +136,14 @@ let redirectTimer: ReturnType | null = null onMounted(async () => { const orderId = Number(route.query.order_id) - const clientSecret = String(route.query.client_secret || '') const method = String(route.query.method || '') const resumeToken = typeof route.query.resume_token === 'string' ? route.query.resume_token : undefined + // client_secret 只从本浏览器下单时写入的恢复快照读取,且快照订单必须与 URL 订单一致: + // 不信任 URL 中的 client_secret,避免页面展示的订单与实际收款的 PaymentIntent 被拼接错绑。 + const restored = typeof window !== 'undefined' + ? readPaymentRecoverySnapshot(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY), { resumeToken }) + : null + const clientSecret = orderId && restored?.orderId === orderId ? restored.clientSecret : '' if (!orderId || !clientSecret) { loading.value = false @@ -147,15 +152,7 @@ onMounted(async () => { } try { - if (typeof window !== 'undefined') { - const restored = readPaymentRecoverySnapshot( - window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY), - { resumeToken }, - ) - if (restored?.orderId === orderId) { - currency.value = normalizePaymentCurrency(restored.currency) - } - } + currency.value = normalizePaymentCurrency(restored?.currency) const res = await paymentAPI.getOrder(orderId) order.value = res.data if (res.data.currency) { diff --git a/frontend/src/views/user/__tests__/StripePaymentView.spec.ts b/frontend/src/views/user/__tests__/StripePaymentView.spec.ts index 0d85126a3f..5d2c6a23f0 100644 --- a/frontend/src/views/user/__tests__/StripePaymentView.spec.ts +++ b/frontend/src/views/user/__tests__/StripePaymentView.spec.ts @@ -65,6 +65,10 @@ vi.mock('@stripe/stripe-js/pure', () => ({ import StripePaymentView from '../StripePaymentView.vue' import { formatPaymentAmount } from '@/components/payment/currency' +import { + PAYMENT_RECOVERY_STORAGE_KEY, + type PaymentRecoverySnapshot, +} from '@/components/payment/paymentFlow' import type { PaymentOrder } from '@/types/payment' function orderFactory(overrides: Partial = {}): PaymentOrder { @@ -86,6 +90,33 @@ function orderFactory(overrides: Partial = {}): PaymentOrder { } } +function stripeSnapshot(overrides: Partial = {}): PaymentRecoverySnapshot { + return { + orderId: 42, + amount: 100, + qrCode: '', + expiresAt: '2099-01-01T00:30:00.000Z', + paymentType: 'stripe', + payUrl: '/payment/stripe?order_id=42', + outTradeNo: 'sub2_stripe_42', + clientSecret: 'pi_secret_42', + intentId: '', + currency: 'CNY', + countryCode: '', + paymentEnv: '', + payAmount: 103, + orderType: 'balance', + paymentMode: '', + resumeToken: '', + createdAt: Date.UTC(2099, 0, 1, 0, 0, 0), + ...overrides, + } +} + +function writeSnapshot(overrides: Partial = {}) { + window.localStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify(stripeSnapshot(overrides))) +} + function mountView() { return shallowMount(StripePaymentView, { global: { @@ -101,7 +132,6 @@ describe('StripePaymentView', () => { beforeEach(() => { routeState.query = { order_id: '42', - client_secret: 'pi_secret_42', } routerPush.mockReset() getOrder.mockReset().mockResolvedValue({ data: orderFactory() }) @@ -119,6 +149,42 @@ describe('StripePaymentView', () => { stripeInstance.confirmAlipayPayment.mockReset() stripeInstance.confirmWechatPayPayment.mockReset() window.localStorage.clear() + writeSnapshot() + }) + + it('uses the client secret from the local snapshot of the same order', async () => { + routeState.query.method = 'alipay' + mountView() + await flushPromises() + await flushPromises() + + expect(getOrder).toHaveBeenCalledWith(42) + expect(stripeInstance.confirmAlipayPayment).toHaveBeenCalledWith('pi_secret_42', expect.any(Object)) + }) + + it('ignores a client secret supplied in the URL query', async () => { + window.localStorage.clear() + routeState.query = { order_id: '42', client_secret: 'pi_other_secret', method: 'alipay' } + + const wrapper = mountView() + await flushPromises() + + expect(getOrder).not.toHaveBeenCalled() + expect(loadStripe).not.toHaveBeenCalled() + expect(stripeInstance.confirmAlipayPayment).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('payment.stripeMissingParams') + }) + + it('rejects a snapshot that belongs to a different order', async () => { + writeSnapshot({ orderId: 7, clientSecret: 'pi_secret_7' }) + routeState.query = { order_id: '42', client_secret: 'pi_secret_42', method: 'alipay' } + + const wrapper = mountView() + await flushPromises() + + expect(getOrder).not.toHaveBeenCalled() + expect(stripeInstance.confirmAlipayPayment).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('payment.stripeMissingParams') }) it.each(['alipay', 'wechat_pay', ''])('does not start %s payment after leaving during SDK loading', async (method) => { @@ -178,7 +244,8 @@ describe('StripePaymentView', () => { expect(vi.getTimerCount()).toBe(0) }) - it('本地恢复快照缺失时使用订单接口返回的 Stripe 币种展示金额', async () => { + it('本地恢复快照未记录币种时使用订单接口返回的 Stripe 币种展示金额', async () => { + writeSnapshot({ currency: '' }) getOrder.mockResolvedValue({ data: orderFactory({ currency: 'HKD', pay_amount: 103 }), })