From eb594eefc952e53ffc8b3494d9023ecd3df03e8c Mon Sep 17 00:00:00 2001 From: ranxi2001 Date: Mon, 24 Aug 2026 22:29:02 +0800 Subject: [PATCH] fix(payment): refresh balance after fulfillment --- frontend/src/views/user/PaymentResultView.vue | 18 ++++++++ .../user/__tests__/PaymentResultView.spec.ts | 42 +++++++++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/user/PaymentResultView.vue b/frontend/src/views/user/PaymentResultView.vue index a5f32f0a8d..981377ce97 100644 --- a/frontend/src/views/user/PaymentResultView.vue +++ b/frontend/src/views/user/PaymentResultView.vue @@ -107,6 +107,7 @@ import { readPaymentRecoverySnapshot, } from '@/components/payment/paymentFlow' import { usePaymentStore } from '@/stores/payment' +import { useAuthStore } from '@/stores/auth' import { paymentAPI } from '@/api/payment' import type { PublicOrderVerifyResult } from '@/api/payment' import type { OrderStatus, PaymentOrder } from '@/types/payment' @@ -118,6 +119,7 @@ const { t } = i18n const route = useRoute() const router = useRouter() const paymentStore = usePaymentStore() +const authStore = useAuthStore() type ResolvedOrder = PaymentOrder | PublicOrderVerifyResult @@ -139,6 +141,7 @@ const STATUS_REFRESH_INTERVAL_MS = 2000 const STATUS_REFRESH_MAX_ATTEMPTS = 15 let statusRefreshTimer: ReturnType | null = null +let userBalanceRefreshStarted = false const refreshAttempts = ref(0) /** 充值金额 = pay_amount / (1 + fee_rate/100),fee_rate=0 时等于 pay_amount */ @@ -197,6 +200,21 @@ function setResolvedOrder(nextOrder: ResolvedOrder | null): void { if (nextOrder && 'currency' in nextOrder && nextOrder.currency) { currency.value = normalizePaymentCurrency(nextOrder.currency) } + refreshUserBalanceForSuccessfulOrder(nextOrder) +} + +function refreshUserBalanceForSuccessfulOrder(nextOrder: ResolvedOrder | null): void { + if (!nextOrder || userBalanceRefreshStarted || normalizeOrderStatus(nextOrder.status) !== 'COMPLETED') { + return + } + if ('order_type' in nextOrder && nextOrder.order_type !== 'balance') { + return + } + + userBalanceRefreshStarted = true + void authStore.refreshUser().catch(() => { + // The order result remains authoritative even if refreshing profile data fails. + }) } function hasOrderId(nextOrder: ResolvedOrder | null): nextOrder is PaymentOrder { diff --git a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts index 5edae835c0..751e7d83c4 100644 --- a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts @@ -10,6 +10,7 @@ const pollOrderStatus = vi.hoisted(() => vi.fn()) const verifyOrder = vi.hoisted(() => vi.fn()) const verifyOrderPublic = vi.hoisted(() => vi.fn()) const resolveOrderPublicByResumeToken = vi.hoisted(() => vi.fn()) +const refreshUser = vi.hoisted(() => vi.fn()) vi.mock('vue-router', async () => { const actual = await vi.importActual('vue-router') @@ -36,6 +37,12 @@ vi.mock('@/stores/payment', () => ({ }), })) +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => ({ + refreshUser, + }), +})) + vi.mock('@/api/payment', () => ({ paymentAPI: { verifyOrder, @@ -91,6 +98,8 @@ describe('PaymentResultView', () => { verifyOrder.mockReset() verifyOrderPublic.mockReset() resolveOrderPublicByResumeToken.mockReset() + refreshUser.mockReset() + refreshUser.mockResolvedValue({}) window.localStorage.clear() }) @@ -171,7 +180,7 @@ describe('PaymentResultView', () => { })) resolveOrderPublicByResumeToken.mockResolvedValue({ data: { - ...orderFactory('PAID'), + ...orderFactory('COMPLETED'), amount: 100, pay_amount: 103, fee_rate: 3, @@ -190,13 +199,14 @@ describe('PaymentResultView', () => { expect(pollOrderStatus).not.toHaveBeenCalled() expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-authoritative') + expect(refreshUser).toHaveBeenCalledTimes(1) expect(wrapper.text()).toContain('payment.result.success') expect(wrapper.text()).toContain('103.00') expect(wrapper.text()).toContain('100.00') expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull() }) - it('refreshes a pending resume-token result until the order becomes paid', async () => { + it('waits for completed fulfillment before refreshing the user balance', async () => { vi.useFakeTimers() routeState.query = { resume_token: 'resume-77', @@ -210,7 +220,7 @@ describe('PaymentResultView', () => { data: orderFactory('PENDING'), }) .mockResolvedValueOnce({ - data: orderFactory('PAID'), + data: orderFactory('COMPLETED'), }) const wrapper = mount(PaymentResultView, { @@ -224,6 +234,7 @@ describe('PaymentResultView', () => { await flushPromises() expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(1) + expect(refreshUser).not.toHaveBeenCalled() expect(wrapper.text()).toContain('payment.result.processing') expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).not.toBeNull() @@ -231,11 +242,36 @@ describe('PaymentResultView', () => { await flushPromises() expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(2) + expect(refreshUser).toHaveBeenCalledTimes(1) expect(wrapper.text()).toContain('payment.result.success') expect(wrapper.text()).not.toContain('payment.result.failed') expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull() }) + it('keeps the successful result when refreshing the user balance fails', async () => { + routeState.query = { + resume_token: 'resume-refresh-failure', + } + resolveOrderPublicByResumeToken.mockResolvedValue({ + data: orderFactory('COMPLETED'), + }) + refreshUser.mockRejectedValueOnce(new Error('profile refresh failed')) + + const wrapper = mount(PaymentResultView, { + global: { + stubs: { + OrderStatusBadge: true, + }, + }, + }) + + await flushPromises() + + expect(refreshUser).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('payment.result.success') + expect(wrapper.text()).not.toContain('payment.result.failed') + }) + it('falls back to order_id polling when resume-token recovery fails', async () => { routeState.query = { resume_token: 'resume-fail',