Skip to content

L25 · 订单系统:状态机设计 ​

🎯 本节目标:实现订单的创建和状态流转,掌握有限状态机设计模式
📦 本节产出:订单 API + 状态机 + 前端订单列表/详情页 + 状态流转可视化
🔗 前置钩子:L24 的购物车结算(订单数据来源)
🔗 后续钩子:L26 将对接支付流程

1. 什么是有限状态机 (FSM) ​

有限状态机是一种经典的设计模式:对象在有限的状态集合中转换,每次转换由特定事件触发,且有严格的合法路径。

订单天然适合用状态机建模——订单不可能从"已取消"变成"已发货"。


2. 后端:状态机实现 ​

2.1 状态转换表 ​

先沿用 L19 的 OrderStatus 枚举。状态合法不代表任何人都能执行:支付只能由 L26 的服务端支付流程触发,发货/退款处理由管理员执行,顾客只能操作自己的订单。

typescript
// 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 事务

typescript
// 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 更新验证限制

typescript
// 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 字符串,也避免顺带返回别人的身份资料。管理员用于商家操作,普通测试账号用于顾客操作。

typescript
// 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 启动逻辑保留:

typescript
// 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. 前端:订单列表页 ​

typescript
// 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 添加。先把订单创建、查询和授权状态变化接通。

vue
<!-- 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 状态时间线组件 ​

vue
<!-- 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 假装整条正常流程都未发生。本组件展示当前状态和模型实际记录的时间,不声称拥有完整的事件历史。

vue
<!-- 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。提交时捕获商品快照;收到成功响应后只扣除这些条目的已购买数量,不按“此刻选中了谁”清空购物车。请求期间阻止路由离开,避免页面卸载后丢掉操作反馈。

vue
<!-- 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>:

typescript
// 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. 防并发:库存扣减的条件更新 ​

订单创建时的库存扣减是一个经典的并发问题:

解决方案:原子操作 + 条件更新

typescript
// 单文档机制示意;完整下单必须使用第 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 提交 ​

bash
git add .
git commit -m "L25: 订单系统 + 状态机 + 库存事务 + 时间线"

🔗 → 下一节 ​

L26 将实现支付流程——模拟支付二维码、轮询支付状态、超时处理,并区分演示回调与真实支付渠道。