Skip to content

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

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

1. 支付流程概览


2. 后端:支付模拟 API

typescript
// server/src/controllers/paymentController.ts
import { Request, Response, NextFunction } from 'express'
import Order from '../models/Order'

// 模拟支付状态存储(实际项目用数据库表)
const payments = new Map<string, {
  orderId: string
  amount: number
  status: 'pending' | 'paid' | 'failed' | 'expired'
  createdAt: Date
}>()

// POST /api/pay/create - 创建支付单
export async function createPayment(req: Request, res: Response, next: NextFunction) {
  try {
    const { orderId } = req.body
    const order = await Order.findById(orderId)

    if (!order) return res.status(404).json({ message: '订单不存在' })
    if (order.status !== 'pending') {
      return res.status(400).json({ message: '订单状态不允许支付' })
    }

    const paymentId = `pay_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`

    payments.set(paymentId, {
      orderId,
      amount: order.totalAmount,
      status: 'pending',
      createdAt: new Date(),
    })

    // 模拟:5-15 秒后自动变为 paid(模拟用户扫码支付)
    const delay = 5000 + Math.random() * 10000
    setTimeout(() => {
      const payment = payments.get(paymentId)
      if (payment && payment.status === 'pending') {
        payment.status = 'paid'
        // 更新订单状态
        Order.findByIdAndUpdate(orderId, {
          status: 'paid',
          paidAt: new Date(),
        }).exec()
      }
    }, delay)

    // 5 分钟后过期
    setTimeout(() => {
      const payment = payments.get(paymentId)
      if (payment && payment.status === 'pending') {
        payment.status = 'expired'
      }
    }, 5 * 60 * 1000)

    res.json({
      data: {
        paymentId,
        amount: order.totalAmount,
        // 模拟二维码 URL(实际会是支付宝/微信的二维码链接)
        qrCodeUrl: `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=PAY:${paymentId}`,
        expiresIn: 300,  // 5 分钟过期
      },
    })
  } catch (error) {
    next(error)
  }
}

// GET /api/pay/:paymentId/status - 查询支付状态
export async function getPaymentStatus(req: Request, res: Response) {
  const payment = payments.get(req.params.paymentId)

  if (!payment) {
    return res.status(404).json({ message: '支付单不存在' })
  }

  res.json({
    data: {
      status: payment.status,
      amount: payment.amount,
      orderId: payment.orderId,
    },
  })
}

3. usePolling Composable

typescript
// client/src/composables/usePolling.ts
import { ref, onUnmounted } from 'vue'

interface UsePollingOptions<T> {
  interval?: number       // 轮询间隔(ms),默认 2000
  maxAttempts?: number    // 最大尝试次数,默认 150 (= 5min / 2s)
  shouldStop?: (data: T) => boolean  // 自定义停止条件
  onSuccess?: (data: T) => void
  onTimeout?: () => void
  onError?: (error: Error) => void
}

export function usePolling<T>(
  pollFn: () => Promise<T>,
  options: UsePollingOptions<T> = {}
) {
  const {
    interval = 2000,
    maxAttempts = 150,
    shouldStop = () => false,
    onSuccess,
    onTimeout,
    onError,
  } = options

  const data = ref<T | null>(null) as Ref<T | null>
  const isPolling = ref(false)
  const attempts = ref(0)
  const error = ref<string | null>(null)

  let timer: ReturnType<typeof setTimeout> | null = null

  async function poll() {
    try {
      const result = await pollFn()
      data.value = result
      attempts.value++

      if (shouldStop(result)) {
        // 满足停止条件
        stop()
        onSuccess?.(result)
        return
      }

      if (attempts.value >= maxAttempts) {
        // 超过最大尝试次数
        stop()
        onTimeout?.()
        return
      }

      // 继续下一次轮询
      timer = setTimeout(poll, interval)
    } catch (err) {
      error.value = (err as Error).message
      onError?.(err as Error)
      // 出错后继续轮询(可配置为停止)
      timer = setTimeout(poll, interval)
    }
  }

  function start() {
    if (isPolling.value) return
    isPolling.value = true
    attempts.value = 0
    error.value = null
    poll()
  }

  function stop() {
    isPolling.value = false
    if (timer) {
      clearTimeout(timer)
      timer = null
    }
  }

  // 组件卸载时自动停止
  onUnmounted(stop)

  return { data, isPolling, attempts, error, start, stop }
}

4. 支付页面

vue
<!-- client/src/views/PaymentView.vue -->
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { usePolling } from '@/composables/usePolling'
import request from '@/utils/request'

const route = useRoute()
const router = useRouter()
const orderId = route.params.id as string

// 支付信息
const paymentInfo = ref<{
  paymentId: string
  qrCodeUrl: string
  amount: number
  expiresIn: number
} | null>(null)

const paymentStatus = ref<'idle' | 'pending' | 'paid' | 'expired' | 'failed'>('idle')

// 倒计时
const countdown = ref(300)
let countdownTimer: ReturnType<typeof setInterval>

// ─── 创建支付单 ───
async function createPayment() {
  try {
    const res = await request.post('/pay/create', { orderId })
    paymentInfo.value = res.data
    countdown.value = res.data.expiresIn
    paymentStatus.value = 'pending'

    // 开始轮询
    startPolling()

    // 开始倒计时
    countdownTimer = setInterval(() => {
      countdown.value--
      if (countdown.value <= 0) {
        clearInterval(countdownTimer)
        paymentStatus.value = 'expired'
        stopPolling()
      }
    }, 1000)
  } catch (err) {
    paymentStatus.value = 'failed'
  }
}

// ─── 轮询支付状态 ───
const { start: startPolling, stop: stopPolling, attempts } = usePolling(
  async () => {
    const res = await request.get(`/pay/${paymentInfo.value!.paymentId}/status`)
    return res.data
  },
  {
    interval: 2000,
    maxAttempts: 150,
    shouldStop: (data: any) => data.status !== 'pending',
    onSuccess: (data: any) => {
      if (data.status === 'paid') {
        paymentStatus.value = 'paid'
        clearInterval(countdownTimer)
        // 延迟跳转,让用户看到成功提示
        setTimeout(() => {
          router.push(`/orders/${orderId}`)
        }, 2000)
      } else if (data.status === 'expired') {
        paymentStatus.value = 'expired'
        clearInterval(countdownTimer)
      }
    },
    onTimeout: () => {
      paymentStatus.value = 'expired'
      clearInterval(countdownTimer)
    },
  }
)

// 格式化倒计时
const formattedCountdown = computed(() => {
  const min = Math.floor(countdown.value / 60)
  const sec = countdown.value % 60
  return `${min}:${sec.toString().padStart(2, '0')}`
})

onMounted(createPayment)
</script>

<template>
  <div class="payment-page">
    <!-- 等待支付 -->
    <div v-if="paymentStatus === 'pending' && paymentInfo" class="payment-pending">
      <h1>扫码支付</h1>
      <p class="amount">
        ¥<strong>{{ paymentInfo.amount.toLocaleString() }}</strong>
      </p>

      <div class="qr-container">
        <img :src="paymentInfo.qrCodeUrl" alt="支付二维码" class="qr-code" />
        <div class="qr-overlay" v-if="countdown <= 30">
          <span class="expiring">即将过期</span>
        </div>
      </div>

      <p class="countdown">
        剩余支付时间:<strong :class="{ warning: countdown <= 60 }">
          {{ formattedCountdown }}
        </strong>
      </p>

      <p class="hint">请使用支付宝或微信扫描二维码完成支付</p>
      <p class="polling-info">正在等待支付结果... (已检查 {{ attempts }} 次)</p>
    </div>

    <!-- 支付成功 -->
    <div v-else-if="paymentStatus === 'paid'" class="payment-success">
      <div class="success-icon">✅</div>
      <h1>支付成功</h1>
      <p>即将跳转到订单详情...</p>
    </div>

    <!-- 支付过期 -->
    <div v-else-if="paymentStatus === 'expired'" class="payment-expired">
      <div class="expired-icon">⏰</div>
      <h1>支付超时</h1>
      <p>二维码已过期,请返回重新发起支付</p>
      <button @click="createPayment()" class="btn-primary">重新支付</button>
      <RouterLink :to="`/orders/${orderId}`" class="btn-text">返回订单</RouterLink>
    </div>

    <!-- 初始化 / 加载 -->
    <div v-else class="payment-loading">
      <p>正在创建支付单...</p>
    </div>
  </div>
</template>

<style scoped>
.payment-page {
  display: flex; 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>

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 处理。