diff --git a/backend/internal/service/payment_resume_lookup.go b/backend/internal/service/payment_resume_lookup.go index 69033afd..048e489a 100644 --- a/backend/internal/service/payment_resume_lookup.go +++ b/backend/internal/service/payment_resume_lookup.go @@ -30,6 +30,15 @@ func (s *PaymentService) GetPublicOrderByResumeToken(ctx context.Context, token if claims.PaymentType != "" && strings.TrimSpace(order.PaymentType) != claims.PaymentType { return nil, fmt.Errorf("resume token payment type mismatch") } + if order.Status == OrderStatusPending || order.Status == OrderStatusExpired { + result := s.checkPaid(ctx, order) + if result == checkPaidResultAlreadyPaid { + order, err = s.entClient.PaymentOrder.Get(ctx, order.ID) + if err != nil { + return nil, fmt.Errorf("reload order by resume token: %w", err) + } + } + } return order, nil } diff --git a/backend/internal/service/payment_resume_lookup_test.go b/backend/internal/service/payment_resume_lookup_test.go index d411398e..39ea24e7 100644 --- a/backend/internal/service/payment_resume_lookup_test.go +++ b/backend/internal/service/payment_resume_lookup_test.go @@ -11,6 +11,35 @@ import ( "github.com/stretchr/testify/require" ) +type paymentResumeLookupProvider struct { + queryCount int +} + +func (p *paymentResumeLookupProvider) Name() string { return "resume-lookup-provider" } + +func (p *paymentResumeLookupProvider) ProviderKey() string { return payment.TypeAlipay } + +func (p *paymentResumeLookupProvider) SupportedTypes() []payment.PaymentType { + return []payment.PaymentType{payment.TypeAlipay} +} + +func (p *paymentResumeLookupProvider) CreatePayment(context.Context, payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) { + panic("unexpected call") +} + +func (p *paymentResumeLookupProvider) QueryOrder(context.Context, string) (*payment.QueryOrderResponse, error) { + p.queryCount++ + return &payment.QueryOrderResponse{Status: payment.ProviderStatusPending}, nil +} + +func (p *paymentResumeLookupProvider) VerifyNotification(context.Context, string, map[string]string) (*payment.PaymentNotification, error) { + panic("unexpected call") +} + +func (p *paymentResumeLookupProvider) Refund(context.Context, payment.RefundRequest) (*payment.RefundResponse, error) { + panic("unexpected call") +} + func TestGetPublicOrderByResumeTokenReturnsMatchingOrder(t *testing.T) { ctx := context.Background() client := newPaymentConfigServiceTestClient(t) @@ -116,3 +145,58 @@ func TestGetPublicOrderByResumeTokenRejectsSnapshotMismatch(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "resume token") } + +func TestGetPublicOrderByResumeTokenChecksUpstreamForPendingOrder(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + user, err := client.User.Create(). + SetEmail("resume-refresh@example.com"). + SetPasswordHash("hash"). + SetUsername("resume-refresh-user"). + Save(ctx) + require.NoError(t, err) + + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(88). + SetPayAmount(88). + SetFeeRate(0). + SetRechargeCode("RESUME-PENDING"). + SetOutTradeNo("sub2_resume_lookup_pending"). + SetPaymentType(payment.TypeAlipay). + SetPaymentTradeNo("trade-pending"). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusPending). + SetExpiresAt(time.Now().Add(time.Hour)). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + Save(ctx) + require.NoError(t, err) + + resumeSvc := NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef")) + token, err := resumeSvc.CreateToken(ResumeTokenClaims{ + OrderID: order.ID, + UserID: user.ID, + PaymentType: payment.TypeAlipay, + CanonicalReturnURL: "https://app.example.com/payment/result", + }) + require.NoError(t, err) + + registry := payment.NewRegistry() + provider := &paymentResumeLookupProvider{} + registry.Register(provider) + + svc := &PaymentService{ + entClient: client, + registry: registry, + resumeService: resumeSvc, + providersLoaded: true, + } + + got, err := svc.GetPublicOrderByResumeToken(ctx, token) + require.NoError(t, err) + require.Equal(t, order.ID, got.ID) + require.Equal(t, 1, provider.queryCount) +} diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue index 8f5a5666..f48036b4 100644 --- a/frontend/src/components/payment/PaymentStatusPanel.vue +++ b/frontend/src/components/payment/PaymentStatusPanel.vue @@ -194,6 +194,10 @@ const countdownDisplay = computed(() => { return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0') }) +function isSuccessStatus(status: string | null | undefined): boolean { + return status === 'COMPLETED' || status === 'PAID' || status === 'RECHARGING' +} + function reopenPopup() { if (props.payUrl) { const win = window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES) @@ -222,7 +226,7 @@ async function pollStatus() { if (!props.orderId || outcome.value) return const order = await paymentStore.pollOrderStatus(props.orderId) if (!order) return - if (order.status === 'COMPLETED' || order.status === 'PAID') { + if (isSuccessStatus(order.status)) { cleanup() paidOrder.value = order setOutcome('success') diff --git a/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts b/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts new file mode 100644 index 00000000..d7017e1f --- /dev/null +++ b/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' + +const pollOrderStatus = vi.hoisted(() => vi.fn()) +const cancelOrder = vi.hoisted(() => vi.fn()) +const showError = vi.hoisted(() => vi.fn()) +const toCanvas = vi.hoisted(() => vi.fn()) + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key, + }), + } +}) + +vi.mock('@/stores/payment', () => ({ + usePaymentStore: () => ({ + pollOrderStatus, + }), +})) + +vi.mock('@/stores', () => ({ + useAppStore: () => ({ + showError, + }), +})) + +vi.mock('@/api/payment', () => ({ + paymentAPI: { + cancelOrder, + }, +})) + +vi.mock('qrcode', () => ({ + default: { + toCanvas, + }, +})) + +import PaymentStatusPanel from '../PaymentStatusPanel.vue' + +const orderFactory = (status: string) => ({ + id: 42, + user_id: 9, + amount: 88, + pay_amount: 88, + fee_rate: 0, + payment_type: 'alipay', + out_trade_no: 'sub2_20260420abcd1234', + status, + order_type: 'balance', + created_at: '2026-04-20T12:00:00Z', + expires_at: '2099-01-01T12:30:00Z', + refund_amount: 0, +}) + +describe('PaymentStatusPanel', () => { + beforeEach(() => { + vi.useFakeTimers() + pollOrderStatus.mockReset() + cancelOrder.mockReset() + showError.mockReset() + toCanvas.mockReset().mockResolvedValue(undefined) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('treats RECHARGING as a successful terminal state', async () => { + pollOrderStatus.mockResolvedValue(orderFactory('RECHARGING')) + + const wrapper = mount(PaymentStatusPanel, { + props: { + orderId: 42, + qrCode: 'https://pay.example.com/qr/42', + expiresAt: '2099-01-01T12:30:00Z', + paymentType: 'alipay', + orderType: 'balance', + }, + global: { + stubs: { + Icon: true, + }, + }, + }) + + await flushPromises() + await vi.advanceTimersByTimeAsync(3000) + await flushPromises() + + expect(pollOrderStatus).toHaveBeenCalledWith(42) + expect(wrapper.text()).toContain('payment.result.success') + expect(wrapper.emitted('success')).toHaveLength(1) + }) +}) diff --git a/frontend/src/views/user/PaymentResultView.vue b/frontend/src/views/user/PaymentResultView.vue index d07bcc29..5177a7fd 100644 --- a/frontend/src/views/user/PaymentResultView.vue +++ b/frontend/src/views/user/PaymentResultView.vue @@ -177,6 +177,15 @@ function isPendingStatus(status: string | null | undefined): boolean { return PENDING_STATUSES.has(normalizeOrderStatus(status)) } +async function resolveOrderFromResumeToken(resumeToken: string): Promise { + try { + const result = await paymentAPI.resolveOrderPublicByResumeToken(resumeToken) + return result.data + } catch (_err: unknown) { + return null + } +} + function clearStatusRefreshTimer(): void { if (statusRefreshTimer !== null) { clearTimeout(statusRefreshTimer) @@ -230,15 +239,13 @@ onMounted(async () => { } } - if (!order.value && resumeToken) { - try { - const result = await paymentAPI.resolveOrderPublicByResumeToken(resumeToken) - order.value = result.data + if (resumeToken) { + const resolvedOrder = await resolveOrderFromResumeToken(resumeToken) + if (resolvedOrder) { + order.value = resolvedOrder if (!orderId) { - orderId = result.data.id + orderId = resolvedOrder.id } - } catch (_err: unknown) { - // Resume token recovery failed; do not trust legacy public out_trade_no fallback. } } @@ -278,12 +285,7 @@ onMounted(async () => { const refreshOrder = async (): Promise => { if (resumeToken) { - try { - const result = await paymentAPI.resolveOrderPublicByResumeToken(resumeToken) - return result.data - } catch (_err: unknown) { - return null - } + return await resolveOrderFromResumeToken(resumeToken) } if (orderId) { diff --git a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts index c4e38523..86c55e25 100644 --- a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts @@ -116,6 +116,58 @@ describe('PaymentResultView', () => { expect(wrapper.text()).not.toContain('payment.result.failed') }) + it('prefers the public resume-token result over a stale restored DB snapshot', async () => { + routeState.query = { + resume_token: 'resume-authoritative', + order_id: '42', + status: 'success', + } + window.localStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify({ + orderId: 42, + amount: 88, + qrCode: '', + expiresAt: '2099-01-01T00:10:00.000Z', + paymentType: 'alipay', + payUrl: 'https://pay.example.com/session/42', + clientSecret: '', + payAmount: 88, + orderType: 'balance', + paymentMode: 'popup', + resumeToken: 'resume-authoritative', + createdAt: Date.UTC(2099, 0, 1, 0, 0, 0), + })) + pollOrderStatus.mockResolvedValue({ + ...orderFactory('PENDING'), + amount: 88, + pay_amount: 88, + fee_rate: 0, + }) + resolveOrderPublicByResumeToken.mockResolvedValue({ + data: { + ...orderFactory('PAID'), + amount: 100, + pay_amount: 103, + fee_rate: 3, + }, + }) + + const wrapper = mount(PaymentResultView, { + global: { + stubs: { + OrderStatusBadge: true, + }, + }, + }) + + await flushPromises() + + expect(pollOrderStatus).toHaveBeenCalledWith(42) + expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-authoritative') + expect(wrapper.text()).toContain('payment.result.success') + expect(wrapper.text()).toContain('103.00') + expect(wrapper.text()).toContain('100.00') + }) + it('refreshes a pending resume-token result until the order becomes paid', async () => { vi.useFakeTimers() routeState.query = {