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 @@
+
+
+
+
+
+
+ 请在 {{ formattedTime }} 内完成支付
+
+
+
+
+
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