diff --git a/frontend/src/modules/ordering/pages/OrderDetailPage.vue b/frontend/src/modules/ordering/pages/OrderDetailPage.vue index 2a67d81675be0933fe11b0a9cf71f44bdc33c8a1..2646f9df61daeb03ced4af93d7664f0a9b01d8e8 100644 --- a/frontend/src/modules/ordering/pages/OrderDetailPage.vue +++ b/frontend/src/modules/ordering/pages/OrderDetailPage.vue @@ -12,9 +12,11 @@ import { v4 as uuidv4 } from 'uuid'; import { useCartStore } from '../../cart/stores/cartStore'; import ConfirmDialog from '@/shared/components/ConfirmDialog.vue'; import OrderStatusBadge from '../components/OrderStatusBadge.vue'; +import OrderPaymentCountdown from '@/shared/components/OrderPaymentCountdown.vue'; import OrderItemList from '../components/OrderItemList.vue'; import { useAuthStore } from '../../identity/stores/authStore'; import { useToast } from '@/shared/composables/useToast'; +import { useOrderTimeoutPolling } from '@/shared/composables/useOrderTimeoutPolling'; import type { OrderDetailData, OrderItemSnapshot } from '../types'; const RECENT_ORDER_WINDOW_MS = 10 * 60 * 1000; // 10 分钟 @@ -173,6 +175,20 @@ function onCancelBuyAgain() { pendingBuyItem.value = null; } +const { start: startTimeoutPolling } = useOrderTimeoutPolling( + orderingApi.getOrderDetail, + () => { + // 订单离开 PENDING_PAYMENT(被超时取消或已支付),刷新详情展示最终状态。 + if (order.value) void loadOrder(order.value.orderId); + }, +); + +function handleExpired() { + if (order.value) { + startTimeoutPolling(order.value.orderId); + } +} + function goPay() { router.push(`/orders/${order.value!.orderId}/payment`); } @@ -397,6 +413,13 @@ async function handleRefund() { + +
diff --git a/frontend/src/modules/payment/pages/PaymentPage.spec.ts b/frontend/src/modules/payment/pages/PaymentPage.spec.ts index 5612ff4495345e5b06f8eca10657ec1f7599a17f..452e3cc894f84d99e2b9081f472196af2cfafa6f 100644 --- a/frontend/src/modules/payment/pages/PaymentPage.spec.ts +++ b/frontend/src/modules/payment/pages/PaymentPage.spec.ts @@ -12,12 +12,17 @@ const pageMocks = vi.hoisted(() => ({ confirm: vi.fn(), clearCurrentPayment: vi.fn(), }, + getOrderDetail: vi.fn(), })); vi.mock('../stores/paymentStore', () => ({ usePaymentStore: () => pageMocks.store, })); +vi.mock('@/modules/ordering/api', () => ({ + getOrderDetail: pageMocks.getOrderDetail, +})); + function payment(orderId: string): PaymentSummary { return { paymentId: `payment-${orderId}`, @@ -45,6 +50,9 @@ describe('PaymentPage', () => { vi.clearAllMocks(); pageMocks.store.error = ''; pageMocks.store.create.mockImplementation(async (orderId: string) => payment(orderId)); + pageMocks.getOrderDetail.mockImplementation(async (orderId: string) => ({ + data: { orderId, status: 'PENDING_PAYMENT', placedAt: '2026-08-04T08:00:00Z' }, + })); }); it('reloads payment data when the route reuses the page for another order', async () => { diff --git a/frontend/src/modules/payment/pages/PaymentPage.vue b/frontend/src/modules/payment/pages/PaymentPage.vue index 36dc96cf79b5f629d046f1623341f9f0829f01f5..1d169568bc7f8cb72370c465d4e3712bd6c17618 100644 --- a/frontend/src/modules/payment/pages/PaymentPage.vue +++ b/frontend/src/modules/payment/pages/PaymentPage.vue @@ -7,6 +7,9 @@ import { ref, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { usePaymentStore } from '../stores/paymentStore'; +import OrderPaymentCountdown from '@/shared/components/OrderPaymentCountdown.vue'; +import { useOrderTimeoutPolling } from '@/shared/composables/useOrderTimeoutPolling'; +import * as orderingApi from '../../ordering/api'; import { PaymentStatusLabel } from '../types'; import type { PaymentSummary } from '../types'; @@ -15,6 +18,7 @@ const router = useRouter(); const store = usePaymentStore(); const activeOrderId = ref(''); +const orderPlacedAt = ref(''); const payment = ref(null); const loading = ref(true); const error = ref(''); @@ -44,7 +48,19 @@ watch( } activeOrderId.value = orderId; + orderPlacedAt.value = ''; loading.value = true; + + // 倒计时起点须用订单下单时间(后端超时从 PlacedAt 起算 30 分钟), + // 而 payment.createdAt 是进入本页才创建的支付记录时间,二者不一致。 + try { + const { data: order } = await orderingApi.getOrderDetail(orderId); + if (generation !== pageGeneration) return; + orderPlacedAt.value = order.placedAt; + } catch { + orderPlacedAt.value = ''; + } + const result = await store.create(orderId); if (generation !== pageGeneration) return; @@ -55,6 +71,22 @@ watch( { immediate: true }, ); +const { start: startTimeoutPolling } = useOrderTimeoutPolling( + orderingApi.getOrderDetail, + (status) => { + if (status === 'CANCELLED') { + alert('订单已超时取消'); + router.replace('/orders'); + } + }, +); + +function handleExpired() { + if (activeOrderId.value) { + startTimeoutPolling(activeOrderId.value); + } +} + async function handleConfirm() { if (!payment.value) return; const generation = pageGeneration; @@ -83,6 +115,13 @@ async function handleConfirm() {

核对订单与金额后完成模拟支付。

+ +
正在创建安全支付记录...

{{ error }}

diff --git a/frontend/src/shared/components/OrderPaymentCountdown.vue b/frontend/src/shared/components/OrderPaymentCountdown.vue new file mode 100644 index 0000000000000000000000000000000000000000..bf56d77d6f4b87836c14c0fe48fb08c060940767 --- /dev/null +++ b/frontend/src/shared/components/OrderPaymentCountdown.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/frontend/src/shared/composables/useOrderTimeoutPolling.spec.ts b/frontend/src/shared/composables/useOrderTimeoutPolling.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..af58a076ff5ef155558458ba39aa9797f7945dec --- /dev/null +++ b/frontend/src/shared/composables/useOrderTimeoutPolling.spec.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useOrderTimeoutPolling } from './useOrderTimeoutPolling'; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('useOrderTimeoutPolling', () => { + it('start 后立即查询一次,订单离开 PENDING_PAYMENT 时回调最终状态', async () => { + const fetchStatus = vi.fn().mockResolvedValue({ data: { status: 'CANCELLED' } }); + const onSettled = vi.fn(); + const { start } = useOrderTimeoutPolling(fetchStatus, onSettled); + + start('101'); + await vi.runAllTimersAsync(); + + expect(fetchStatus).toHaveBeenCalledTimes(1); + expect(fetchStatus).toHaveBeenCalledWith('101'); + expect(onSettled).toHaveBeenCalledTimes(1); + expect(onSettled).toHaveBeenCalledWith('CANCELLED'); + }); + + it('订单持续 PENDING_PAYMENT 时按间隔轮询,达到 maxAttempts 后停止', async () => { + const fetchStatus = vi + .fn() + .mockResolvedValue({ data: { status: 'PENDING_PAYMENT' } }); + const onSettled = vi.fn(); + const { start } = useOrderTimeoutPolling(fetchStatus, onSettled, { + intervalMs: 5000, + maxAttempts: 3, + }); + + start('101'); + await vi.runAllTimersAsync(); + + expect(fetchStatus).toHaveBeenCalledTimes(3); + expect(onSettled).not.toHaveBeenCalled(); + }); + + it('stop 后不再轮询', async () => { + const fetchStatus = vi + .fn() + .mockResolvedValue({ data: { status: 'PENDING_PAYMENT' } }); + const onSettled = vi.fn(); + const { start, stop } = useOrderTimeoutPolling(fetchStatus, onSettled); + + start('101'); + await vi.advanceTimersByTimeAsync(0); + stop(); + await vi.runAllTimersAsync(); + + expect(fetchStatus).toHaveBeenCalledTimes(1); + expect(onSettled).not.toHaveBeenCalled(); + }); + + it('单次查询失败不中断轮询,下一轮继续', async () => { + const fetchStatus = vi + .fn() + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValue({ data: { status: 'CANCELLED' } }); + const onSettled = vi.fn(); + const { start } = useOrderTimeoutPolling(fetchStatus, onSettled, { + intervalMs: 5000, + }); + + start('101'); + await vi.runAllTimersAsync(); + + expect(fetchStatus).toHaveBeenCalledTimes(2); + expect(onSettled).toHaveBeenCalledWith('CANCELLED'); + }); +}); diff --git a/frontend/src/shared/composables/useOrderTimeoutPolling.ts b/frontend/src/shared/composables/useOrderTimeoutPolling.ts new file mode 100644 index 0000000000000000000000000000000000000000..a57abfa2e42e5802e77b2d3fa55f8e609cd1e58d --- /dev/null +++ b/frontend/src/shared/composables/useOrderTimeoutPolling.ts @@ -0,0 +1,81 @@ +/** + * 订单超时轮询:倒计时归零后周期查询订单状态,直到其离开 PENDING_PAYMENT。 + * + * 存在的理由:后端 OrderTimeoutWorker 每 60 秒才扫描一次,前端倒计时归零只代表 + * "本地时间到了",订单可能还要再等最多 60 秒才真正被取消为 CANCELLED。若只在 + * 归零瞬间查一次,很可能仍读到 PENDING_PAYMENT 而漏掉最终结果。本模块在归零后 + * 按固定间隔轮询,直到订单进入终态(CANCELLED / PAID)再通过 onSettled 回调。 + * + * 与 UI 倒计时组件解耦:倒计时组件只负责"本地剩余时间",不关心订单、不发请求; + * 页面把"归零后确认真实状态"交给本模块。 + */ +import { getCurrentInstance, onUnmounted } from 'vue'; + +export interface OrderTimeoutPollingOptions { + /** 轮询间隔(毫秒)。后端扫描间隔 60 秒,5 秒足够及时感知,又不过度请求。 */ + intervalMs?: number; + /** 最大轮询次数,避免订单长期悬而未决时无限轮询。默认 24 次(约 2 分钟)。 */ + maxAttempts?: number; +} + +/** 订单状态查询函数,形如 orderingApi.getOrderDetail 的返回结构。 */ +export type OrderStatusFetcher = ( + orderId: string, +) => Promise<{ data: { status: string } }>; + +export function useOrderTimeoutPolling( + fetchStatus: OrderStatusFetcher, + onSettled: (status: string) => void, + options?: OrderTimeoutPollingOptions, +) { + const intervalMs = options?.intervalMs ?? 5000; + const maxAttempts = options?.maxAttempts ?? 24; + + let timer: ReturnType | null = null; + let attempts = 0; + let inFlight = false; + + function stop() { + if (timer !== null) { + clearInterval(timer); + timer = null; + } + attempts = 0; + inFlight = false; + } + + async function poll(orderId: string) { + if (inFlight) return; + inFlight = true; + attempts += 1; + try { + const { data } = await fetchStatus(orderId); + if (data.status !== 'PENDING_PAYMENT') { + stop(); + onSettled(data.status); + return; + } + } catch { + // 单次查询失败静默,等下一轮重试;不因瞬时网络抖动中断轮询。 + } finally { + inFlight = false; + } + if (attempts >= maxAttempts) stop(); + } + + function start(orderId: string) { + stop(); + if (!orderId) return; + // 先立即查一次,避免归零后还要等一个完整间隔才首次确认状态。 + void poll(orderId); + timer = setInterval(() => void poll(orderId), intervalMs); + } + + // 有组件实例时自动清理;测试/非组件上下文(getCurrentInstance 为空)跳过, + // 避免 onUnmounted 在无实例环境下告警。 + if (getCurrentInstance()) { + onUnmounted(stop); + } + + return { start, stop }; +} diff --git a/frontend/src/shared/utils/http.spec.ts b/frontend/src/shared/utils/http.spec.ts index fc22d3826f24331a8e15f72de3683462f9bc86db..5fdc0167c66643b671ab1b7d94850fef7c543de1 100644 --- a/frontend/src/shared/utils/http.spec.ts +++ b/frontend/src/shared/utils/http.spec.ts @@ -309,6 +309,23 @@ describe('http authentication retry', () => { expect(new Headers(requestInit?.headers).get('X-CSRF-Token')).toBe('1'); }); + it.each([ + ['v3', '"v3"'], + ['"v1"', '"v1"'], + ])('normalizes the If-Match header to a quoted strong ETag (%s)', async (input, expected) => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + void input; + void init; + return envelope(200, null); + }); + vi.stubGlobal('fetch', fetchMock); + + await http.post('/merchant/products/35/status', { status: 'active' }, { etag: input }); + + const requestInit = fetchMock.mock.calls[0]?.[1]; + expect(new Headers(requestInit?.headers).get('If-Match')).toBe(expected); + }); + it('maps a rejected fetch to NetworkError without clearing authentication state', async () => { const refresh = vi.fn(async () => true); setUnauthorizedHandler(refresh); diff --git a/frontend/src/shared/utils/http.ts b/frontend/src/shared/utils/http.ts index da6de6ce524b80ff68ff7ee8fa20e765cfacc534..d8d8565dedbc4c36a3fb545d174a714e8d95e057 100644 --- a/frontend/src/shared/utils/http.ts +++ b/frontend/src/shared/utils/http.ts @@ -209,9 +209,11 @@ async function request( headers['Authorization'] = `Bearer ${requestAccessToken}`; } - // ETag + // ETag:响应头 ETag 已带引号("vN"),行内 etag 字段不带引号(vN)。 + // 后端 StrongEntityTag.ParseRequired 要求强 ETag(带引号),因此统一规范化。 if (options.etag) { - headers['If-Match'] = options.etag; + const etag = options.etag.trim(); + headers['If-Match'] = etag.startsWith('"') && etag.endsWith('"') ? etag : `"${etag}"`; } // Idempotency-Key