Skip to content

L26 · 支付模拟:轮询与异步编排 ​

🎯 本节目标:模拟支付流程——生成支付二维码、轮询支付状态、超时处理
📦 本节产出:支付页面 + usePolling composable + 页面内支付结果状态
🔗 前置钩子:L25 的订单系统(pending 状态等待支付)
🔗 后续钩子:L27 将实现商品图片上传

1. 支付流程概览 ​


2. 后端:支付模拟 API ​

这是本机模拟流程,不会扣款,也不接支付宝/微信。二维码只是 MOCK_PAY:支付单ID 文本;点击页面上的“模拟确认支付”按钮才发送模拟确认请求。不要引导读者拿真实支付 App 扫码付款。

在 server/ 安装二维码库,二维码在本地生成,不把支付标识交给公共图片 API:

bash
npm install qrcode@1
npm install -D @types/qrcode@1

toDataURL 直接返回可用于 img src 的二维码图片。node-qrcode 官方 API

仅在本机 .env 加入 ENABLE_MOCK_PAYMENT=true。服务端同时拒绝 production 环境的模拟接口;真实支付接入时应删除模拟确认端点,使用渠道签名验证、金额核对和回调去重。

typescript
// server/src/controllers/paymentController.ts
import type { Request, Response } from 'express'
import { randomUUID } from 'node:crypto'
import QRCode from 'qrcode'
import Order from '../models/Order'
import { AppError } from '../utils/AppError'
import { success } from '../utils/response'
import { ORDER_TIMEOUT_MS, transitionOrder } from '../services/orderService'

type PaymentStatus = 'pending' | 'paid' | 'failed' | 'expired'
interface Payment { id: string; orderId: string; userId: string; amount: number; expiresAt: number; createdAt: number }
const payments = new Map<string, Payment>()
function enabled(req: Request): string {
  if (process.env.ENABLE_MOCK_PAYMENT !== 'true' || process.env.NODE_ENV === 'production') {
    throw new AppError('模拟支付未启用', 403)
  }
  if (!req.userId) throw new AppError('请先登录', 401)
  return req.userId
}
async function paymentFor(req: Request) {
  const userId = enabled(req)
  const payment = payments.get(String(req.params.paymentId))
  if (!payment || payment.userId !== userId) throw new AppError('支付单不存在,请从订单重新发起', 404)
  const order = await Order.findOne({ _id: payment.orderId, user: userId })
  if (!order) throw new AppError('订单不存在', 404)
  return { payment, order }
}
function statusOf(payment: Payment, order: { paidAt?: Date; status: string }): PaymentStatus {
  // paidAt 是支付曾成功的记录;后续退款不会把这一历史变成未支付。
  if (order.paidAt) return 'paid'
  if (order.status !== 'pending') return 'failed'
  return Date.now() >= payment.expiresAt ? 'expired' : 'pending'
}

export async function createPayment(req: Request, res: Response) {
  const userId = enabled(req)
  const orderId = req.body?.orderId
  if (typeof orderId !== 'string' || !/^[a-f\d]{24}$/i.test(orderId)) throw new AppError('无效的订单 id')
  const order = await Order.findOne({ _id: orderId, user: userId })
  if (!order) throw new AppError('订单不存在', 404)
  const now = Date.now()
  const orderDeadline = order.createdAt.getTime() + ORDER_TIMEOUT_MS
  if (order.status !== 'pending' || orderDeadline <= now) throw new AppError('订单已不能支付', 409)
  // 清除一天前的演示记录;真正订单与支付成功状态仍在数据库中。
  for (const [id, payment] of payments) if (payment.createdAt < now - 86400000) payments.delete(id)
  let payment = [...payments.values()].find(value => value.orderId === order._id.toString() && value.expiresAt > now)
  if (!payment) {
    payment = { id: randomUUID(), orderId: order._id.toString(), userId, amount: order.totalAmount,
      createdAt: now, expiresAt: Math.min(now + 5 * 60 * 1000, orderDeadline) }
    payments.set(payment.id, payment)
  }
  success(res, {
    paymentId: payment.id, orderId: payment.orderId, amount: payment.amount,
    qrCodeUrl: await QRCode.toDataURL(`MOCK_PAY:${payment.id}`),
    expiresAt: new Date(payment.expiresAt).toISOString(),
  })
}
export async function getPaymentStatus(req: Request, res: Response) {
  const { payment, order } = await paymentFor(req)
  success(res, { status: statusOf(payment, order), amount: payment.amount, orderId: payment.orderId })
}
export async function confirmMockPayment(req: Request, res: Response) {
  const { payment, order } = await paymentFor(req)
  const status = statusOf(payment, order)
  if (status === 'paid') { success(res, { status, amount: payment.amount, orderId: payment.orderId }); return }
  if (status !== 'pending') throw new AppError('支付单已过期或订单已关闭', 409)
  try { await transitionOrder(payment.orderId, 'paid', { kind: 'payment' }) }
  catch (error) {
    // 两次确认竞争时,第一笔已提交的支付结果可以复用,不能二次改状态。
    const latest = await Order.findOne({ _id: payment.orderId, user: payment.userId })
    if (!latest?.paidAt) throw error
  }
  success(res, { status: 'paid' as const, amount: payment.amount, orderId: payment.orderId })
}

这里以数据库里的订单状态为准,查询状态不会自行“支付”,也不再用没有 await 的定时回调覆盖订单。支付与取消竞争时,由 L25 的事务和状态条件决定哪次成功;已取消订单不能被迟到确认改成 paid。重复确认成功返回同一结果,不再次产生状态变化。

Map 只保存单进程的模拟支付单,重启后旧 paymentId 返回 404,需回到订单重新发起;已提交的 paidAt 不会随 Map 丢失。它没有持久化支付账本或渠道对账能力。

typescript
// server/src/routes/paymentRoutes.ts
import { Router } from 'express'
import { authMiddleware } from '../middlewares/auth'
import { createPayment, getPaymentStatus, confirmMockPayment } from '../controllers/paymentController'
const router = Router()
router.use(authMiddleware)
router.post('/create', createPayment)
router.get('/:paymentId/status', getPaymentStatus)
router.post('/:paymentId/confirm', confirmMockPayment)
export default router

在 app.ts 导入 import paymentRoutes from './routes/paymentRoutes',在 404 之前增加 app.use('/api/pay', paymentRoutes)。

typescript
// client/src/api/payments.ts
import request from '@/utils/request'
import type { ApiResponse } from '@/types/api'
export interface PaymentInfo { paymentId: string; orderId: string; amount: number; qrCodeUrl: string; expiresAt: string }
export interface PaymentResult { status: 'pending' | 'paid' | 'failed' | 'expired'; amount: number; orderId: string }
export const paymentApi = {
  create(orderId: string, signal?: AbortSignal) { return request.post<ApiResponse<PaymentInfo>>('/pay/create', { orderId }, { signal }) },
  status(paymentId: string, signal?: AbortSignal) { return request.get<ApiResponse<PaymentResult>>(`/pay/${encodeURIComponent(paymentId)}/status`, { signal }) },
  confirm(paymentId: string) { return request.post<ApiResponse<PaymentResult>>(`/pay/${encodeURIComponent(paymentId)}/confirm`) },
}

3. usePolling Composable ​

轮询采用“上次请求结束后再等 interval”,避免请求重叠。maxAttempts 包含失败的尝试,它不是准确的倒计时;耗时请求和后台页面节流会改变实际间隔。另设 timeoutMs 限制本次等待的总时长。

typescript
// client/src/composables/usePolling.ts
import { ref, shallowRef, onScopeDispose } from 'vue'
import axios from 'axios'
interface UsePollingOptions<T> {
  interval?: number
  maxAttempts?: number
  timeoutMs?: number
  shouldStop?: (data: T) => boolean
  onSuccess?: (data: T) => void
  onTimeout?: () => void
  onError?: (error: Error) => void
  stopOnError?: (error: Error) => boolean
}
export function usePolling<T>(pollFn: (signal: AbortSignal) => Promise<T>, options: UsePollingOptions<T> = {}) {
  const data = shallowRef<T | null>(null)
  const isPolling = ref(false)
  const attempts = ref(0)
  const error = ref<string | null>(null)
  let timer: ReturnType<typeof setTimeout> | undefined
  let timeout: ReturnType<typeof setTimeout> | undefined
  let controller: AbortController | undefined
  let version = 0
  let disposed = false
  function stop() {
    version++
    isPolling.value = false
    clearTimeout(timer)
    clearTimeout(timeout)
    controller?.abort()
  }
  function start(timeoutMs = options.timeoutMs ?? 300000) {
    if (disposed || isPolling.value) return
    stop()
    const current = version
    const active = new AbortController()
    controller = active
    const deadline = Date.now() + Math.max(0, timeoutMs)
    const alive = () => current === version && isPolling.value && !disposed
    const expire = () => { if (alive()) { stop(); options.onTimeout?.() } }
    isPolling.value = true
    attempts.value = 0
    error.value = null
    data.value = null
    timeout = setTimeout(expire, Math.max(0, timeoutMs))
    async function poll() {
      if (!alive()) return
      if (Date.now() >= deadline || attempts.value >= (options.maxAttempts ?? 150)) { expire(); return }
      attempts.value++
      try {
        const result = await pollFn(active.signal)
        if (!alive()) return
        if (Date.now() >= deadline) { expire(); return }
        data.value = result
        error.value = null
        if (options.shouldStop?.(result)) { stop(); options.onSuccess?.(result); return }
      } catch (cause) {
        if (!alive() || active.signal.aborted || axios.isCancel(cause)) return
        const failure = cause instanceof Error ? cause : new Error('查询失败')
        error.value = failure.message
        options.onError?.(failure)
        if (options.stopOnError?.(failure) ?? true) { stop(); return }
      }
      if (alive()) timer = setTimeout(() => { void poll() }, options.interval ?? 2000)
    }
    void poll()
  }
  onScopeDispose(() => { disposed = true; stop() })
  return { data, isPolling, attempts, error, start, stop }
}

stop 会取消计时器和在途读取,版本号阻止迟到响应重新启动轮询。默认查询出错就停,调用方可以提供 stopOnError 决定是否重试;即使持续失败,次数和时间上限仍然有效。


4. 支付页面 ​

vue
<!-- client/src/views/PaymentView.vue -->
<script setup lang="ts">
import { ref, watch, computed, onScopeDispose } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
import { usePolling } from '@/composables/usePolling'
import { useRequest } from '@/composables/useRequest'
import { paymentApi, type PaymentInfo, type PaymentResult } from '@/api/payments'
const route = useRoute()
const orderId = computed(() => typeof route.params.id === 'string' ? route.params.id : '')
const paymentInfo = ref<PaymentInfo | null>(null)
const paymentStatus = ref<'idle' | 'pending' | 'paid' | 'expired' | 'failed'>('idle')
const message = ref('')
const now = ref(Date.now())
let clock: ReturnType<typeof setInterval> | undefined
let generation = 0
const countdown = computed(() => Math.max(0, Math.ceil(((paymentInfo.value ? Date.parse(paymentInfo.value.expiresAt) : 0) - now.value) / 1000)))
const formattedCountdown = computed(() => `${Math.floor(countdown.value / 60)}:${String(countdown.value % 60).padStart(2, '0')}`)
function finish(result: PaymentResult) {
  message.value = ''
  paymentStatus.value = result.status
  clearInterval(clock)
}
const { start: startPolling, stop: stopPolling, attempts } = usePolling(
  async signal => {
    if (!paymentInfo.value) throw new Error('尚未创建支付单')
    return (await paymentApi.status(paymentInfo.value.paymentId, signal)).data
  }, {
    interval: 2000, maxAttempts: 150,
    shouldStop: result => result.status !== 'pending',
    onSuccess: finish,
    onTimeout: () => {
      // 前端等待超时不是支付失败证据,结果可能已在服务器提交。
      paymentStatus.value = 'expired'
      message.value = '等待已结束,请到订单详情核对最终状态。'
      clearInterval(clock)
    },
    onError: error => { paymentStatus.value = 'failed'; message.value = error.message; clearInterval(clock) },
  },
)
const { loading: creating, execute: create, cancel, error: createError } = useRequest(
  (signal, id: string) => paymentApi.create(id, signal),
)
async function createPayment() {
  if (creating.value) return
  const current = ++generation
  stopPolling()
  clearInterval(clock)
  paymentStatus.value = 'idle'
  paymentInfo.value = null
  message.value = ''
  const result = await create(orderId.value)
  if (current !== generation) return
  if (!result) { paymentStatus.value = 'failed'; message.value = createError.value || '创建支付单失败'; return }
  paymentInfo.value = result.data
  paymentStatus.value = 'pending'
  now.value = Date.now()
  clock = setInterval(() => { now.value = Date.now() }, 1000)
  startPolling(Math.max(0, Date.parse(result.data.expiresAt) - Date.now()))
}
const confirming = ref(false)
async function confirmMock() {
  if (!paymentInfo.value || confirming.value) return
  const current = generation
  const id = paymentInfo.value.paymentId
  confirming.value = true
  try {
    const result = await paymentApi.confirm(id)
    if (current === generation) { stopPolling(); finish(result.data) }
  } catch (error) {
    if (current === generation) message.value = error instanceof Error ? error.message : '确认失败,请查询订单状态'
  } finally { if (current === generation) confirming.value = false }
}
watch(orderId, () => {
  generation++
  confirming.value = false
  cancel()
  stopPolling()
  clearInterval(clock)
  void createPayment()
}, { immediate: true })
onScopeDispose(() => { generation++; clearInterval(clock) })
</script>

<template>
  <div class="payment-page">
    <div v-if="paymentStatus === 'pending' && paymentInfo" class="payment-pending">
      <h1>模拟支付</h1>
      <p class="amount">¥<strong>{{ paymentInfo.amount.toFixed(2) }}</strong></p>
      <div class="qr-container"><img :src="paymentInfo.qrCodeUrl" alt="模拟支付标识二维码,不可真实付款" class="qr-code" /></div>
      <p class="countdown">剩余有效期:<strong :class="{ warning: countdown <= 60 }">{{ formattedCountdown }}</strong></p>
      <p class="hint">此页面不会扣款。二维码只包含演示标识,请用下面按钮模拟成功。</p>
      <button class="btn-primary" :disabled="confirming || countdown === 0" @click="confirmMock">{{ confirming ? '确认中…' : '模拟确认支付' }}</button>
      <p class="polling-info">正在检查结果({{ attempts }} 次)</p>
    </div>
    <div v-else-if="paymentStatus === 'paid'" class="payment-success"><h1>模拟支付已确认</h1></div>
    <div v-else-if="paymentStatus === 'expired'" class="payment-expired">
      <h1>支付等待已结束</h1><p>先核对订单状态,仍待支付且未超时的订单可以重新发起。</p>
      <button class="btn-primary" :disabled="creating" @click="createPayment">重新发起</button>
    </div>
    <div v-else-if="paymentStatus === 'failed'" class="payment-expired">
      <h1>暂时无法完成支付</h1><button class="btn-primary" :disabled="creating" @click="createPayment">重新获取支付单</button>
    </div>
    <div v-else class="payment-loading"><p>正在创建支付单…</p></div>
    <p v-if="message" role="alert">{{ message }}</p>
    <RouterLink :to="{ name: 'order-detail', params: { id: orderId } }" class="btn-text">查看订单最终状态</RouterLink>
  </div>
</template>

<style scoped>
.payment-page {
  display: flex; flex-direction: column; justify-content: center; align-items: center;
  min-height: 70vh; padding: 24px;
}

.payment-pending, .payment-success, .payment-expired, .payment-loading {
  text-align: center; max-width: 400px;
}

.amount {
  font-size: 1.1rem; color: #666; margin: 8px 0 24px;
}
.amount strong { font-size: 2rem; color: #e74c3c; }

.qr-container {
  position: relative; display: inline-block;
  padding: 16px; background: white;
  border: 1px solid #e0e0e0; border-radius: 12px;
  box-shadow: 0 2px 12px rgba(0,0,0,0.06);
  margin-bottom: 20px;
}
.qr-code { width: 200px; height: 200px; }
.qr-overlay {
  position: absolute; inset: 0; background: rgba(255,255,255,0.85);
  display: flex; align-items: center; justify-content: center;
  border-radius: 12px;
}
.expiring { color: #e74c3c; font-weight: 700; font-size: 1.1rem; }

.countdown { font-size: 0.9rem; color: #666; }
.countdown .warning { color: #e74c3c; }
.hint { font-size: 0.8rem; color: #999; margin-top: 12px; }
.polling-info { font-size: 0.75rem; color: #bbb; margin-top: 8px; }

.success-icon, .expired-icon { font-size: 4rem; margin-bottom: 16px; }
.payment-success h1 { color: #42b883; }
.payment-expired h1 { color: #e74c3c; }

.btn-primary {
  padding: 10px 28px; background: #42b883; color: white;
  border: none; border-radius: 8px; cursor: pointer; font-size: 0.95rem;
  margin-top: 16px;
}
.btn-text {
  display: block; margin-top: 12px; color: #666;
  text-decoration: none; font-size: 0.85rem;
}
</style>

向当前路由数组加入支付页,使用 L22 的登录守卫:

typescript
// client/src/router/index.ts:routes 数组新增项
{ path: '/pay/:id', name: 'payment', component: () => import('@/views/PaymentView.vue'), meta: { requiresAuth: true } },

在 L25 的订单列表 order-actions 中增加 <RouterLink v-if="order.status === 'pending'" :to="{ name: 'payment', params: { id: order._id } }">去模拟支付</RouterLink>;详情页对应使用 data.data.status / data.data._id。不要用 updateStatus('paid') 代替支付 API。

结果直接显示在支付页,并提供订单链接;没有延迟跳转计时器,用户可以核对提示后再离开。


5. 轮询 vs WebSocket vs SSE ​

方案原理优点缺点适用
轮询定时发 HTTP 请求实现简单、兼容性好增加查询请求;延迟受间隔影响支付状态 ✅
长轮询服务端 hold 请求直到有数据实时性好连接占用消息推送
WebSocket双向持久连接通常延迟较低复杂度高聊天、协作
SSE服务端单向推送简单、自动重连只能服务端→客户端通知、股票

本例两秒一次查询足以演示状态变化。实际轮询间隔取决于并发量、服务端限流与可接受延迟;无论前端选轮询还是推送,到账判定都来自服务端验证的渠道结果。


6. 本节总结 ​

检查清单 ​

  • [ ] 能实现模拟支付 API(创建支付单 + 查询状态)
  • [ ] 能封装 usePolling composable(interval / maxAttempts / shouldStop)
  • [ ] 能实现支付二维码页面 + 倒计时
  • [ ] 能显示支付结果并到订单页核对最终状态
  • [ ] 能区分前端等待超时、支付单过期和订单截止时间
  • [ ] 能在组件卸载时自动停止轮询
  • [ ] 理解轮询 vs WebSocket vs SSE 的选型

Git 提交 ​

bash
git add .
git commit -m "L26: 支付模拟 + usePolling + 倒计时 + 状态处理"

🔗 → 下一节 ​

L27 将实现商品图片上传——拖拽上传、图片预览、上传进度条、服务端 multer 处理。