L25 · 订单系统:状态机设计
🎯 本节目标:实现订单的创建和状态流转,掌握有限状态机设计模式
📦 本节产出:订单 API + 状态机 + 前端订单列表/详情页 + 状态流转可视化
🔗 前置钩子:L24 的购物车结算(订单数据来源)
🔗 后续钩子:L26 将对接支付流程1. 什么是有限状态机 (FSM)
有限状态机是一种经典的设计模式:对象在有限的状态集合中转换,每次转换由特定事件触发,且有严格的合法路径。
订单天然适合用状态机建模——订单不可能从"已取消"变成"已发货"。
2. 后端:状态机实现
2.1 状态转换表
先沿用 L19 的 OrderStatus 枚举。状态合法不代表任何人都能执行:支付只能由 L26 的服务端支付流程触发,发货/退款处理由管理员执行,顾客只能操作自己的订单。
// server/src/utils/orderStateMachine.ts
import type { IOrder, OrderStatus } from '../models/Order'
export type OrderActor = { kind: 'owner' | 'admin' | 'payment' | 'timeout'; userId?: string }
const validTransitions: Record<OrderStatus, readonly OrderStatus[]> = {
pending: ['paid', 'cancelled'],
paid: ['shipped', 'refunding'],
shipped: ['delivered', 'refunding'],
delivered: ['reviewed'],
refunding: ['refunded', 'paid', 'shipped'],
refunded: [], reviewed: [], cancelled: [],
}
export const STATUS_META: Record<OrderStatus, { label: string; color: string; icon: string; isFinal: boolean }> = {
pending: { label: '待支付', color: '#f59e0b', icon: '⏳', isFinal: false },
paid: { label: '已支付', color: '#3b82f6', icon: '💳', isFinal: false },
shipped: { label: '已发货', color: '#8b5cf6', icon: '🚚', isFinal: false },
delivered: { label: '已收货', color: '#42b883', icon: '📦', isFinal: false },
reviewed: { label: '已评价', color: '#059669', icon: '⭐', isFinal: true },
refunding: { label: '退款中', color: '#ef4444', icon: '↩️', isFinal: false },
refunded: { label: '已退款', color: '#6b7280', icon: '💰', isFinal: true },
cancelled: { label: '已取消', color: '#9ca3af', icon: '❌', isFinal: true },
}
export function canTransition(from: OrderStatus, to: OrderStatus) { return validTransitions[from].includes(to) }
export function getNextStates(current: OrderStatus) { return [...validTransitions[current]] }
export function isFinalState(status: OrderStatus) { return STATUS_META[status].isFinal }
export function getAllowedActions(order: Pick<IOrder, 'status' | 'refundFrom'>, kind: OrderActor['kind']): OrderStatus[] {
if (kind === 'payment') return order.status === 'pending' ? ['paid'] : []
if (kind === 'timeout') return order.status === 'pending' ? ['cancelled'] : []
const allowed: OrderStatus[] = kind === 'owner' ? ['cancelled', 'refunding', 'delivered', 'reviewed']
: ['shipped', 'refunded', 'paid']
return getNextStates(order.status).filter(next => {
if (!allowed.includes(next)) return false
if (next === 'paid' && order.status === 'pending') return false
if (order.status === 'refunding' && (next === 'paid' || next === 'shipped')) return next === order.refundFrom
return true
})
}给 L19 的 IOrder 增加 refundFrom?: 'paid' | 'shipped',并在 orderSchema 中增加 refundFrom: { type: String, enum: ['paid', 'shipped'] }。L21 前端 Order 类型同样追加该可选字段。它记录发起退款前的状态,拒绝退款时不能把已发货订单错误退回“已支付”。
映射表集中描述转换路径;增加状态时仍要同步 Schema、权限、库存副作用和前端展示,不能只加一行就算完成。
2.2 订单控制器
库存扣减、订单写入和取消补库存均在 MongoDB 事务中执行。请先按 L19 启动副本集;Standalone MongoDB 不支持这里的多文档事务。所有数据库操作显式传同一 session,事务内部按顺序执行,不用 Promise.all。Mongoose 8 事务
// server/src/services/orderService.ts
import mongoose from 'mongoose'
import Order, { type IOrderItem, type OrderStatus } from '../models/Order'
import Product from '../models/Product'
import User from '../models/User'
import { AppError } from '../utils/AppError'
import { getAllowedActions, type OrderActor } from '../utils/orderStateMachine'
export const ORDER_TIMEOUT_MS = 30 * 60 * 1000
export interface OrderInput {
items: { productId: string; quantity: number }[]
shippingAddress: { name: string; phone: string; city: string; address: string }
}
export async function placeOrder(userId: string, input: OrderInput) {
return mongoose.connection.transaction(async session => {
if (!(await User.exists({ _id: userId }).session(session))) throw new AppError('用户不存在', 401)
const orderItems: IOrderItem[] = []
let cents = 0
for (const item of input.items) {
const product = await Product.findOneAndUpdate(
{ _id: item.productId, isActive: true, stock: { $gte: item.quantity, $lte: Number.MAX_SAFE_INTEGER } },
{ $inc: { stock: -item.quantity } },
{ new: true, session },
)
if (!product) throw new AppError('商品已下架或库存不足,请刷新购物车', 409)
const unitCents = Math.round(product.price * 100)
const subtotal = unitCents * item.quantity
if (!Number.isSafeInteger(unitCents) || unitCents < 0 || !Number.isSafeInteger(subtotal) ||
!Number.isSafeInteger(cents + subtotal)) throw new AppError('订单金额超出支持范围')
cents += subtotal
orderItems.push({ product: product._id, name: product.name, price: unitCents / 100,
quantity: item.quantity, image: product.images[0] || '' })
}
const [order] = await Order.create([{
user: userId, items: orderItems, totalAmount: cents / 100,
shippingAddress: input.shippingAddress, status: 'pending', paymentMethod: 'mock',
}], { session })
return order
})
}
export async function transitionOrder(id: string, target: OrderStatus, actor: OrderActor) {
return mongoose.connection.transaction(async session => {
const order = await Order.findById(id).session(session)
if (!order || (actor.kind === 'owner' && order.user.toString() !== actor.userId)) {
throw new AppError('订单不存在', 404)
}
if (!getAllowedActions(order, actor.kind).includes(target)) throw new AppError('当前状态不允许此操作', 409)
if (actor.kind === 'timeout' && order.createdAt.getTime() > Date.now() - ORDER_TIMEOUT_MS) {
throw new AppError('订单尚未超时', 409)
}
if (actor.kind === 'payment' && order.createdAt.getTime() <= Date.now() - ORDER_TIMEOUT_MS) {
throw new AppError('订单已超过支付期限', 409)
}
const from = order.status
// 未付款取消、未发货退款恢复库存;已发货退款不代表实物已经退回。
const restoreStock = target === 'cancelled' || (target === 'refunded' && order.refundFrom === 'paid')
if (restoreStock) {
for (const item of order.items) {
const result = await Product.updateOne(
{ _id: item.product, stock: { $lte: Number.MAX_SAFE_INTEGER - item.quantity } },
{ $inc: { stock: item.quantity } }, { session },
)
if (result.matchedCount !== 1) throw new AppError('库存恢复失败', 409)
}
}
if (target === 'refunding' && (from === 'paid' || from === 'shipped')) order.refundFrom = from
if (from === 'refunding' && target !== 'refunding') order.refundFrom = undefined
order.status = target
if (target === 'paid' && from === 'pending') order.paidAt = new Date()
if (target === 'delivered') order.deliveredAt = new Date()
await order.save({ session })
return order
})
}
// 数据库扫描能在进程重启后继续发现过期订单;并发扫描靠事务保证不重复补库存。
export async function expirePendingOrders() {
const orders = await Order.find({ status: 'pending', createdAt: { $lte: new Date(Date.now() - ORDER_TIMEOUT_MS) } })
.select('_id').limit(100).lean()
for (const order of orders) {
try { await transitionOrder(order._id.toString(), 'cancelled', { kind: 'timeout' }) }
catch (error) {
if (!(error instanceof AppError && error.statusCode === 409)) throw error
}
}
}$inc 不会因为 Schema 的 min 或 runValidators 自动避免负数,条件 stock >= quantity 才参与本次原子扣减。前面商品成功、后面失败时抛错,整个事务回滚。订单状态更新与补库存也共同提交,因此两个取消请求不能各补一次库存。MongoDB 原子性、Mongoose 更新验证限制
// server/src/controllers/orderController.ts
import type { Request, Response } from 'express'
import type { FilterQuery } from 'mongoose'
import Order, { orderStatuses, type IOrder, type OrderStatus } from '../models/Order'
import User from '../models/User'
import { AppError } from '../utils/AppError'
import { success, successWithPagination } from '../utils/response'
import { getAllowedActions, type OrderActor } from '../utils/orderStateMachine'
import { placeOrder, transitionOrder, type OrderInput } from '../services/orderService'
function userIdOf(req: Request): string {
if (!req.userId) throw new AppError('请先登录', 401)
return req.userId
}
function objectBody(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new AppError('请求体必须是对象')
return value as Record<string, unknown>
}
function statusOf(value: unknown): OrderStatus {
if (typeof value !== 'string' || !orderStatuses.includes(value as OrderStatus)) throw new AppError('无效的订单状态')
return value as OrderStatus
}
function integer(value: unknown, fallback: number, max: number): number {
if (value === undefined) return fallback
if (typeof value !== 'string' || !/^\d+$/.test(value)) throw new AppError('无效的分页参数')
const result = Number(value)
if (!Number.isSafeInteger(result) || result < 1 || result > max) throw new AppError('无效的分页参数')
return result
}
function orderInput(value: unknown): OrderInput {
const body = objectBody(value)
if (!Array.isArray(body.items) || body.items.length === 0 || body.items.length > 100) throw new AppError('订单需包含 1–100 种商品')
const seen = new Set<string>()
const items = body.items.map(value => {
const item = objectBody(value)
if (typeof item.productId !== 'string' || !/^[a-f\d]{24}$/i.test(item.productId) ||
typeof item.quantity !== 'number' || !Number.isSafeInteger(item.quantity) || item.quantity < 1) {
throw new AppError('商品 id 或数量不正确')
}
const productId = item.productId.toLowerCase()
if (seen.has(productId)) throw new AppError('订单中不能重复提交同一商品')
seen.add(productId)
return { productId, quantity: item.quantity }
})
const address = objectBody(body.shippingAddress)
const text = (key: string, max: number) => {
const value = address[key]
if (typeof value !== 'string' || !value.trim() || value.trim().length > max) throw new AppError(`收货信息 ${key} 不正确`)
return value.trim()
}
return { items, shippingAddress: { name: text('name', 50), phone: text('phone', 30), city: text('city', 80), address: text('address', 200) } }
}
export async function createOrder(req: Request, res: Response) {
const order = await placeOrder(userIdOf(req), orderInput(req.body))
success(res, order, 201)
}
export async function updateOrderStatus(req: Request, res: Response) {
const userId = userIdOf(req)
const user = await User.findById(userId)
if (!user) throw new AppError('用户不存在', 401)
const actor: OrderActor = { kind: user.role === 'admin' ? 'admin' : 'owner', userId }
const order = await transitionOrder(String(req.params.id), statusOf(objectBody(req.body).status), actor)
success(res, order)
}
export async function getMyOrders(req: Request, res: Response) {
const page = integer(req.query.page, 1, 1000000)
const limit = integer(req.query.limit, 10, 50)
const query: FilterQuery<IOrder> = { user: userIdOf(req) }
if (req.query.status !== undefined) query.status = statusOf(req.query.status)
const [orders, total] = await Promise.all([
Order.find(query).sort({ createdAt: -1, _id: -1 }).skip((page - 1) * limit).limit(limit).lean(),
Order.countDocuments(query),
])
successWithPagination(res, orders, { page, limit, total })
}
export async function getOrderById(req: Request, res: Response) {
const userId = userIdOf(req)
const user = await User.findById(userId)
if (!user) throw new AppError('用户不存在', 401)
const order = await Order.findById(req.params.id).lean()
if (!order || (user.role !== 'admin' && order.user.toString() !== userId)) throw new AppError('订单不存在', 404)
const kind = user.role === 'admin' ? 'admin' : 'owner'
success(res, { ...order, availableActions: getAllowedActions(order, kind) })
}创建接口限制 100 种商品,是本示例的请求大小约束。客户端只提交 id、数量和地址;总价、状态、用户 id 都由服务端确定。订单详情不再 populate user,传输类型中的 user 仍是 id 字符串,也避免顺带返回别人的身份资料。管理员用于商家操作,普通测试账号用于顾客操作。
// server/src/routes/orderRoutes.ts
import { Router } from 'express'
import { authMiddleware } from '../middlewares/auth'
import { createOrder, updateOrderStatus, getMyOrders, getOrderById } from '../controllers/orderController'
const router = Router()
router.use(authMiddleware)
router.post('/', createOrder)
router.get('/my', getMyOrders) // 必须在 /:id 之前
router.get('/:id', getOrderById)
router.patch('/:id/status', updateOrderStatus)
export default router在 app.ts 顶部增加 import orderRoutes from './routes/orderRoutes',并在 404 之前注册 app.use('/api/orders', orderRoutes)。在 index.ts 顶部导入 expirePendingOrders,在数据库/用户索引就绪后启动扫描;HTTP 启动逻辑保留:
// server/src/index.ts:start() 中 connectDB / User.init 后新增
let scanning = false
async function scanExpired() {
if (scanning) return
scanning = true
try { await expirePendingOrders() }
catch (error) { console.error('过期订单扫描失败', error) }
finally { scanning = false }
}
void scanExpired()
setInterval(() => { void scanExpired() }, 60000).unref()导入语句是 import { expirePendingOrders } from './services/orderService'。扫描一次最多处理 100 单,约一分钟检查一次,积压时还需后续批次,因此不是精确到秒的过期任务。L26 的支付检查同样使用订单创建时间加 30 分钟,避免扫描间隔内仍付款。应用退出后不会继续扫描,重启后恢复;生产调度、重试告警和关停流程另行设计。
3. 前端:订单列表页
// client/src/utils/orderStatus.ts
import type { OrderStatus } from '@/types/order'
export const statusMap: Record<OrderStatus, { label: string; color: string; icon: string }> = {
pending: { label: '待支付', color: '#f59e0b', icon: '⏳' },
paid: { label: '已支付', color: '#3b82f6', icon: '💳' },
shipped: { label: '已发货', color: '#8b5cf6', icon: '🚚' },
delivered: { label: '已收货', color: '#42b883', icon: '📦' },
reviewed: { label: '已评价', color: '#059669', icon: '⭐' },
refunding: { label: '退款中', color: '#ef4444', icon: '↩️' },
refunded: { label: '已退款', color: '#6b7280', icon: '💰' },
cancelled: { label: '已取消', color: '#9ca3af', icon: '❌' },
}
export const actionLabels: Partial<Record<OrderStatus, string>> = {
cancelled: '取消订单', delivered: '确认收货', refunding: '申请退款', reviewed: '标记已评价',
shipped: '确认发货 / 恢复已发货', refunded: '确认退款', paid: '拒绝退款并恢复已支付',
}
export function customerActions(status: OrderStatus): OrderStatus[] {
if (status === 'pending') return ['cancelled']
if (status === 'paid') return ['refunding']
if (status === 'shipped') return ['delivered', 'refunding']
if (status === 'delivered') return ['reviewed']
return []
}本课用“标记已评价”演示状态变化,没有评价正文或评分发布功能;支付页面由 L26 添加。先把订单创建、查询和授权状态变化接通。
<!-- client/src/views/OrderListView.vue -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { orderApi } from '@/api/orders'
import { useRequest } from '@/composables/useRequest'
import type { OrderStatus } from '@/types/order'
import { statusMap, actionLabels, customerActions } from '@/utils/orderStatus'
const activeTab = ref<OrderStatus | 'all'>('all')
const page = ref(1)
const tabs = [
{ key: 'all', label: '全部' }, { key: 'pending', label: '待支付' },
{ key: 'paid', label: '已支付' }, { key: 'shipped', label: '待收货' },
{ key: 'delivered', label: '已收货' },
] as const
const { data: orderData, loading, error, execute: fetchOrders } = useRequest(signal =>
orderApi.getMyOrders({ status: activeTab.value === 'all' ? undefined : activeTab.value, page: page.value, limit: 20 }, signal),
)
watch([activeTab, page], () => { void fetchOrders() }, { immediate: true })
function switchTab(key: OrderStatus | 'all') { activeTab.value = key; page.value = 1 }
const acting = ref(false)
const actionError = ref('')
async function handleAction(orderId: string, action: OrderStatus) {
if (acting.value) return
acting.value = true
actionError.value = ''
try { await orderApi.updateStatus(orderId, action); await fetchOrders() }
catch (error) { actionError.value = error instanceof Error ? error.message : '操作失败' }
finally { acting.value = false }
}
</script>
<template>
<div class="order-page">
<h1>📋 我的订单</h1>
<!-- Tab 筛选栏 -->
<div class="order-tabs">
<button
v-for="tab in tabs"
:key="tab.key"
class="tab-btn"
:class="{ active: activeTab === tab.key }"
@click="switchTab(tab.key)"
>
{{ tab.label }}
</button>
</div>
<p v-if="actionError" role="alert">{{ actionError }}</p>
<!-- 加载、错误与空状态要区分 -->
<div v-if="loading" class="loading-state">加载中...</div>
<div v-else-if="error" role="alert">{{ error }} <button @click="fetchOrders()">重试</button></div>
<div v-else-if="!orderData?.data.length" class="empty-state">
<p>暂无订单</p>
<RouterLink to="/products" class="btn-primary">去购物</RouterLink>
</div>
<!-- 订单列表 -->
<div v-else-if="orderData" class="order-list">
<div v-for="order in orderData.data" :key="order._id" class="order-card">
<!-- 订单头部 -->
<div class="order-header">
<RouterLink :to="{ name: 'order-detail', params: { id: order._id } }" class="order-id">订单号:{{ order._id.slice(-8).toUpperCase() }}</RouterLink>
<span class="order-time">
{{ new Date(order.createdAt).toLocaleString() }}
</span>
<span
class="order-status"
:style="{ color: statusMap[order.status]?.color }"
>
{{ statusMap[order.status]?.icon }}
{{ statusMap[order.status]?.label }}
</span>
</div>
<!-- 商品列表 -->
<div class="order-items">
<div v-for="item in order.items" :key="item.product" class="order-item">
<img v-if="item.image" :src="item.image" :alt="item.name" class="item-img" />
<div class="item-info">
<span class="item-name">{{ item.name }}</span>
<span class="item-qty">× {{ item.quantity }}</span>
</div>
<span class="item-price">¥{{ (item.price * item.quantity).toFixed(2) }}</span>
</div>
</div>
<!-- 订单底部 -->
<div class="order-footer">
<span class="order-total">
合计:<strong>¥{{ order.totalAmount.toFixed(2) }}</strong>
</span>
<div class="order-actions">
<button v-for="action in customerActions(order.status)" :key="action" :disabled="acting"
class="btn-text" @click="handleAction(order._id, action)">
{{ actionLabels[action] }}
</button>
</div>
</div>
</div>
</div>
<nav v-if="!loading && !error && orderData && orderData.pagination.totalPages > 1" aria-label="订单分页">
<button :disabled="page <= 1" @click="page--">上一页</button>
<span>第 {{ page }} / {{ orderData.pagination.totalPages }} 页</span>
<button :disabled="page >= orderData.pagination.totalPages" @click="page++">下一页</button>
</nav>
</div>
</template>
<style scoped>
.order-page { padding: 24px; max-width: 800px; margin: 0 auto; }
.order-page h1 { font-size: 1.5rem; margin-bottom: 16px; }
/* Tab 栏 */
.order-tabs { display: flex; gap: 4px; margin-bottom: 20px; border-bottom: 1px solid #eee; }
.tab-btn {
padding: 10px 20px; border: none; background: none;
cursor: pointer; font-size: 0.9rem; color: #666;
border-bottom: 2px solid transparent; transition: all 0.2s;
}
.tab-btn.active { color: #42b883; border-bottom-color: #42b883; font-weight: 600; }
/* 订单卡片 */
.order-card {
border: 1px solid #e8e8e8; border-radius: 12px;
margin-bottom: 16px; overflow: hidden;
}
.order-header {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px; background: #fafafa;
font-size: 0.85rem;
}
.order-id { font-family: monospace; color: #333; }
.order-time { color: #999; flex: 1; }
.order-status { font-weight: 600; }
.order-items { padding: 0 16px; }
.order-item {
display: flex; align-items: center; gap: 12px;
padding: 12px 0; border-bottom: 1px solid #f0f0f0;
}
.order-item:last-child { border-bottom: none; }
.item-img { width: 56px; height: 56px; border-radius: 6px; object-fit: cover; }
.item-info { flex: 1; }
.item-name { display: block; font-size: 0.9rem; }
.item-qty { font-size: 0.8rem; color: #999; }
.item-price { font-weight: 600; color: #333; }
.order-footer {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; background: #fafafa;
}
.order-total { font-size: 0.9rem; }
.order-total strong { font-size: 1.1rem; color: #e74c3c; }
.order-actions { display: flex; gap: 8px; }
.btn-primary {
padding: 6px 18px; border: none; border-radius: 6px;
background: #42b883; color: white; font-size: 0.85rem; cursor: pointer;
}
.btn-outline {
padding: 6px 18px; border: 1px solid #42b883; border-radius: 6px;
background: white; color: #42b883; font-size: 0.85rem; cursor: pointer;
}
.btn-text {
padding: 6px 12px; border: none; background: none;
color: #666; font-size: 0.85rem; cursor: pointer;
}
.btn-text.danger { color: #e74c3c; }
</style>4. 状态机在前端的体现
4.1 根据状态动态显示操作按钮
列表按顾客可操作状态显示按钮,详情使用后端返回的 availableActions。它们都只是操作入口;服务端还要再次检查订单归属、操作者角色和当前状态。页面展示之后,别的请求仍可能改变订单状态。
4.2 状态时间线组件
<!-- client/src/components/OrderTimeline.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import type { OrderStatus } from '@/types/order'
import { statusMap } from '@/utils/orderStatus'
const props = defineProps<{ status: OrderStatus; createdAt: string; paidAt?: string; deliveredAt?: string }>()
const steps = ['pending', 'paid', 'shipped', 'delivered', 'reviewed'] as const
const currentIndex = computed(() => (steps as readonly string[]).indexOf(props.status))
</script>
<template>
<section aria-label="订单进度">
<ol v-if="currentIndex >= 0" class="timeline">
<li v-for="(step, index) in steps" :key="step" :class="{ active: index <= currentIndex }"
:aria-current="step === status ? 'step' : undefined">{{ statusMap[step].label }}</li>
</ol>
<p v-else>当前分支:{{ statusMap[status].label }}</p>
<p>下单:{{ new Date(createdAt).toLocaleString() }}</p>
<p v-if="paidAt">支付:{{ new Date(paidAt).toLocaleString() }}</p>
<p v-if="deliveredAt">收货:{{ new Date(deliveredAt).toLocaleString() }}</p>
</section>
</template>
<style scoped>
.timeline { display: flex; flex-wrap: wrap; gap: 16px; padding: 16px; list-style: none; color: #777; }
.active { color: #21845d; font-weight: 600; }
</style>取消和退款属于分支,不能用 indexOf 的 -1 假装整条正常流程都未发生。本组件展示当前状态和模型实际记录的时间,不声称拥有完整的事件历史。
<!-- client/src/views/OrderDetailView.vue -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
import { orderApi } from '@/api/orders'
import { useRequest } from '@/composables/useRequest'
import type { OrderStatus } from '@/types/order'
import { statusMap, actionLabels } from '@/utils/orderStatus'
import OrderTimeline from '@/components/OrderTimeline.vue'
const route = useRoute()
const { data, loading, error, execute } = useRequest((signal, id: string) => orderApi.getById(id, signal))
const acting = ref(false)
const actionError = ref('')
watch(() => route.params.id, id => {
actionError.value = ''
if (typeof id === 'string') void execute(id)
}, { immediate: true })
async function act(status: OrderStatus) {
if (!data.value || acting.value) return
const id = data.value.data._id
acting.value = true
actionError.value = ''
try {
await orderApi.updateStatus(id, status)
if (route.params.id === id) await execute(id)
} catch (error) {
if (route.params.id === id) actionError.value = error instanceof Error ? error.message : '操作失败'
}
finally { acting.value = false }
}
</script>
<template>
<main>
<RouterLink to="/orders">返回我的订单</RouterLink>
<p v-if="loading">加载中…</p><p v-else-if="error" role="alert">{{ error }}</p>
<article v-else-if="data">
<h1>订单 {{ data.data._id }}</h1><p>{{ statusMap[data.data.status].label }}</p>
<OrderTimeline :status="data.data.status" :created-at="data.data.createdAt"
:paid-at="data.data.paidAt" :delivered-at="data.data.deliveredAt" />
<ul><li v-for="item in data.data.items" :key="item.product">{{ item.name }} × {{ item.quantity }}</li></ul>
<p>金额:¥{{ data.data.totalAmount.toFixed(2) }}</p>
<p>收货:{{ data.data.shippingAddress.name }} · {{ data.data.shippingAddress.city }} {{ data.data.shippingAddress.address }}</p>
<button v-for="action in data.data.availableActions" :key="action" :disabled="acting" @click="act(action)">{{ actionLabels[action] }}</button>
<p v-if="actionError" role="alert">{{ actionError }}</p>
</article>
</main>
</template>4.3 把结算预览接到创建订单
替换 L24 的 CheckoutView。提交时捕获商品快照;收到成功响应后只扣除这些条目的已购买数量,不按“此刻选中了谁”清空购物车。请求期间阻止路由离开,避免页面卸载后丢掉操作反馈。
<!-- client/src/views/CheckoutView.vue -->
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { RouterLink, useRouter, onBeforeRouteLeave } from 'vue-router'
import { useCartStore } from '@/stores/cartStore'
import { useAuthStore } from '@/stores/authStore'
import { orderApi } from '@/api/orders'
import type { ShippingAddress } from '@/types/order'
const cart = useCartStore()
const auth = useAuthStore()
const router = useRouter()
const address = reactive<ShippingAddress>({ name: '', phone: '', city: '', address: '' })
const pending = ref(false)
const error = ref('')
onBeforeRouteLeave(() => !pending.value)
async function submit() {
if (pending.value || cart.selectedItems.length === 0) return
const selected = cart.selectedItems.map(item => ({ item, productId: item.productId, quantity: item.quantity }))
const session = auth.sessionVersion()
pending.value = true
error.value = ''
try {
const result = await orderApi.create({
items: selected.map(({ productId, quantity }) => ({ productId, quantity })),
shippingAddress: { ...address },
})
if (session !== auth.sessionVersion()) throw new Error('订单请求已完成,但登录状态发生变化;请重新登录查看订单')
for (const entry of selected) {
const current = cart.items.find(item => item.productId === entry.productId)
// 被删除后重新添加的是另一个条目,保留它;新增的其他商品也保留。
if (current !== entry.item) continue
if (current.quantity <= entry.quantity) cart.removeItem(entry.productId)
else cart.updateQuantity(entry.productId, current.quantity - entry.quantity)
}
pending.value = false
await router.push({ name: 'order-detail', params: { id: result.data._id } })
} catch (cause) { error.value = cause instanceof Error ? cause.message : '创建订单失败' }
finally { pending.value = false }
}
</script>
<template>
<main><h1>结算</h1>
<p v-if="cart.selectedItems.length === 0">尚未选择商品。</p>
<form v-else @submit.prevent="submit">
<ul><li v-for="item in cart.selectedItems" :key="item.productId">{{ item.name }} × {{ item.quantity }}</li></ul>
<p>预估金额:¥{{ cart.totalPrice.toFixed(2) }};订单金额由服务端重新计算。</p>
<fieldset :disabled="pending"><legend>收货信息</legend>
<label>收货人 <input v-model="address.name" required maxlength="50" autocomplete="name" /></label>
<label>电话 <input v-model="address.phone" required maxlength="30" autocomplete="tel" /></label>
<label>城市 <input v-model="address.city" required maxlength="80" autocomplete="address-level2" /></label>
<label>详细地址 <input v-model="address.address" required maxlength="200" autocomplete="street-address" /></label>
<button>{{ pending ? '提交中…' : '创建订单' }}</button>
</fieldset>
</form>
<p v-if="error" role="alert">{{ error }}</p><RouterLink to="/cart">返回购物车</RouterLink>
</main>
</template>提交按钮防止当前页面连点,不能证明服务端只创建一次。请求超时可能发生在事务已提交之后;本课未实现创建订单的幂等键,遇到结果不确定先到订单列表核对,不要自动重试下单。L26 的支付模拟会单独处理重复确认。
向当前路由数组新增订单列表/详情,并在 App header 增加 <RouterLink to="/orders">我的订单</RouterLink>:
// client/src/router/index.ts:routes 数组新增项
{ path: '/orders', name: 'orders', component: () => import('@/views/OrderListView.vue'), meta: { requiresAuth: true } },
{ path: '/orders/:id', name: 'order-detail', component: () => import('@/views/OrderDetailView.vue'), meta: { requiresAuth: true } },5. 防并发:库存扣减的条件更新
订单创建时的库存扣减是一个经典的并发问题:
解决方案:原子操作 + 条件更新
// 单文档机制示意;完整下单必须使用第 2 节的事务实现
const result = await Product.findOneAndUpdate(
{ _id: productId, isActive: true, stock: { $gte: quantity } },
{ $inc: { stock: -quantity } },
{ new: true },
)
if (!result) throw new Error('库存不足')条件更新解决同一商品的竞争,不能让已经完成的多个写入自动回滚。第 2 节把各商品条件扣减与 Order.create 放在一个事务中;optimisticConcurrency 保护文档保存时的版本检查,不能替代多文档事务。
6. 本节总结
检查清单
- [ ] 理解有限状态机(FSM)的概念和适用场景
- [ ] 能用
validTransitions对象定义合法状态转换 - [ ] 能实现
canTransition()和getNextStates()工具函数 - [ ] 能实现创建订单时的库存校验和扣减
- [ ] 理解条件更新与事务分别解决单商品竞争和多文档回滚
- [ ] 能在前端根据订单状态动态渲染操作按钮
- [ ] 能实现订单状态时间线组件
- [ ] 能实现超时自动取消机制
本节边界
| 内容 | 本节状态 | 说明 |
|---|---|---|
| 状态机与权限 | 已实现 | 顾客、管理员、支付流程和超时扫描有不同操作范围 |
| 库存扣减/恢复 | 已实现事务 | 依赖副本集,所有操作使用同一 session |
| 超时取消 | 已实现数据库扫描 | 重启后恢复,扫描间隔和批次不保证准点 |
| 下单幂等 | 未实现 | 防连点不等于服务端去重,超时后先核对订单 |
| 退款/评价 | 状态演示 | 未接真实退款渠道、退货入库或评价内容 |
| 时间线 | 当前状态与已有时间戳 | 未提供完整的状态事件历史 |
课后练习
练习 1:跟做(15 min) 按照本节代码完整实现订单创建 → 状态流转 → 前端列表渲染。
练习 2:举一反三(20 min) 为状态机增加一个新状态 returning(退货中),定义从 delivered → returning → returned 的转换路径,并同步 Schema、操作者权限、前端 statusMap 和操作按钮;明确退货入库发生在哪个事件。
挑战题(30 min) 测试事务:第二种商品库存不足时,断言第一种商品库存没有减少、订单没有创建;再并发提交两张争抢最后一件库存的订单,断言只有一张成功。
真实项目补充
| 实践 | 说明 |
|---|---|
| MongoDB 事务 | 同一 session 内提交订单、库存和相关状态;部署需支持事务 |
| 后台过期任务 | 独立调度/延迟队列需考虑积压、重试和重复执行 |
| 幂等性 | 同一用户/幂等键关联原请求与结果,并建立唯一约束;随机新订单号不能去重重试 |
| 并发验证 | 用真实数据库验证库存竞争、取消与支付竞争、事务重试 |
| 订单快照 | 下单时冻结商品价格/名称,防止后续商品修改影响历史订单 |
Git 提交
git add .
git commit -m "L25: 订单系统 + 状态机 + 库存事务 + 时间线"🔗 → 下一节
L26 将实现支付流程——模拟支付二维码、轮询支付状态、超时处理,并区分演示回调与真实支付渠道。