L26 · 支付模拟:轮询与异步编排
🎯 本节目标:模拟支付流程——生成支付二维码、轮询支付状态、超时处理
📦 本节产出:支付页面 + usePolling composable + 页面内支付结果状态
🔗 前置钩子:L25 的订单系统(pending 状态等待支付)
🔗 后续钩子:L27 将实现商品图片上传1. 支付流程概览
2. 后端:支付模拟 API
这是本机模拟流程,不会扣款,也不接支付宝/微信。二维码只是 MOCK_PAY:支付单ID 文本;点击页面上的“模拟确认支付”按钮才发送模拟确认请求。不要引导读者拿真实支付 App 扫码付款。
在 server/ 安装二维码库,二维码在本地生成,不把支付标识交给公共图片 API:
npm install qrcode@1
npm install -D @types/qrcode@1toDataURL 直接返回可用于 img src 的二维码图片。node-qrcode 官方 API
仅在本机 .env 加入 ENABLE_MOCK_PAYMENT=true。服务端同时拒绝 production 环境的模拟接口;真实支付接入时应删除模拟确认端点,使用渠道签名验证、金额核对和回调去重。
// 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 丢失。它没有持久化支付账本或渠道对账能力。
// 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)。
// 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 限制本次等待的总时长。
// 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. 支付页面
<!-- 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 的登录守卫:
// 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 提交
git add .
git commit -m "L26: 支付模拟 + usePolling + 倒计时 + 状态处理"🔗 → 下一节
L27 将实现商品图片上传——拖拽上传、图片预览、上传进度条、服务端 multer 处理。