diff --git a/frontend/src/views/user/PaymentResultView.vue b/frontend/src/views/user/PaymentResultView.vue index 6431ddf6..e1db3ce2 100644 --- a/frontend/src/views/user/PaymentResultView.vue +++ b/frontend/src/views/user/PaymentResultView.vue @@ -94,6 +94,7 @@ import { ref, computed, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue' +import { PAYMENT_RECOVERY_STORAGE_KEY, readPaymentRecoverySnapshot } from '@/components/payment/paymentFlow' import { usePaymentStore } from '@/stores/payment' import { paymentAPI } from '@/api/payment' import type { PaymentOrder } from '@/types/payment' @@ -129,46 +130,46 @@ const feeAmount = computed(() => { }) const isSuccess = computed(() => { - // Always prioritize actual order status from backend - if (order.value) { - return SUCCESS_STATUSES.has(order.value.status) - } - // Fallback only when order not loaded - if (route.query.status === 'success') return true - if (route.query.trade_status === 'TRADE_SUCCESS') return true - return false + return !!order.value && SUCCESS_STATUSES.has(order.value.status) }) -/** Extract numeric order ID from out_trade_no like "sub2_46" → 46 */ -function parseOutTradeNo(outTradeNo: string): number { - const match = outTradeNo.match(/_(\d+)$/) - return match ? Number(match[1]) : 0 -} - onMounted(async () => { - // Try order_id first (internal navigation from QRCode/Stripe pages) + const resumeToken = typeof route.query.resume_token === 'string' + ? route.query.resume_token + : '' let orderId = Number(route.query.order_id) || 0 const outTradeNo = String(route.query.out_trade_no || '') - // Fallback: EasyPay return URL with out_trade_no - if (!orderId && outTradeNo) { - orderId = parseOutTradeNo(outTradeNo) - // Store return info for display when order lookup fails + if (!orderId && resumeToken && typeof window !== 'undefined') { + const restored = readPaymentRecoverySnapshot( + window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY), + { resumeToken }, + ) + if (restored?.orderId) { + orderId = restored.orderId + } + } + + if (orderId) { + try { + order.value = await paymentStore.pollOrderStatus(orderId) + } catch (_err: unknown) { + // Order lookup failed, will try legacy fallback below when possible. + } + } + + if (!order.value && outTradeNo) { returnInfo.value = { outTradeNo, money: String(route.query.money || ''), type: String(route.query.type || ''), tradeStatus: String(route.query.trade_status || ''), } - } - // Verify payment via public endpoint (works without login) - if (outTradeNo) { try { const result = await paymentAPI.verifyOrderPublic(outTradeNo) order.value = result.data } catch (_err: unknown) { - // Public verify failed, try authenticated endpoint if logged in try { const result = await paymentAPI.verifyOrder(outTradeNo) order.value = result.data @@ -176,12 +177,11 @@ onMounted(async () => { } } - // Normal order lookup by ID (if verify didn't load the order) if (!order.value && orderId) { try { order.value = await paymentStore.pollOrderStatus(orderId) } catch (_err: unknown) { - // Order lookup failed, will show returnInfo fallback + // Order lookup failed, will show returnInfo fallback. } } loading.value = false diff --git a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts new file mode 100644 index 00000000..b06217ab --- /dev/null +++ b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' + +const routeState = vi.hoisted(() => ({ + query: {} as Record, +})) + +const routerPush = vi.hoisted(() => vi.fn()) +const pollOrderStatus = vi.hoisted(() => vi.fn()) +const verifyOrderPublic = vi.hoisted(() => vi.fn()) +const verifyOrder = vi.hoisted(() => vi.fn()) + +vi.mock('vue-router', async () => { + const actual = await vi.importActual('vue-router') + return { + ...actual, + useRoute: () => routeState, + useRouter: () => ({ push: routerPush }), + } +}) + +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('@/api/payment', () => ({ + paymentAPI: { + verifyOrderPublic, + verifyOrder, + }, +})) + +import PaymentResultView from '../PaymentResultView.vue' +import { PAYMENT_RECOVERY_STORAGE_KEY } from '@/components/payment/paymentFlow' + +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: '2026-04-20T12:30:00Z', + refund_amount: 0, +}) + +describe('PaymentResultView', () => { + beforeEach(() => { + routeState.query = {} + routerPush.mockReset() + pollOrderStatus.mockReset() + verifyOrderPublic.mockReset() + verifyOrder.mockReset() + window.localStorage.clear() + }) + + it('restores order id from a matching resume token and does not trust query success flags', async () => { + routeState.query = { + resume_token: 'resume-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: 'redirect', + resumeToken: 'resume-42', + createdAt: Date.UTC(2099, 0, 1, 0, 0, 0), + })) + pollOrderStatus.mockResolvedValue(orderFactory('PENDING')) + + const wrapper = mount(PaymentResultView, { + global: { + stubs: { + OrderStatusBadge: true, + }, + }, + }) + + await flushPromises() + + expect(pollOrderStatus).toHaveBeenCalledWith(42) + expect(verifyOrderPublic).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('payment.result.failed') + expect(wrapper.text()).not.toContain('payment.result.success') + }) + + it('keeps legacy out_trade_no verification as a fallback when no order context is available', async () => { + routeState.query = { + out_trade_no: 'legacy-123', + trade_status: 'TRADE_SUCCESS', + } + verifyOrderPublic.mockResolvedValue({ + data: orderFactory('PAID'), + }) + + const wrapper = mount(PaymentResultView, { + global: { + stubs: { + OrderStatusBadge: true, + }, + }, + }) + + await flushPromises() + + expect(verifyOrderPublic).toHaveBeenCalledWith('legacy-123') + expect(wrapper.text()).toContain('payment.result.success') + }) +})