Skip to content

Lesson 23:购物车与订单 — 混合状态管理 ​

🧩 本节信息卡(学习前先看) ​

  • 阶段定位:Phase 3(实战篇)
  • 推荐时长:120~180 分钟(首次学习)
  • 先修要求:完成 L19–22,已有整数分金额模型与 requireUser 认证函数
  • 学习产出:建立可持久化的购物车、服务端校验的待支付订单与取消流程
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

  1. 学习目标:先明确本节要解决的业务问题与核心 API。
  2. 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
  3. 原理深挖:理解为什么这样设计,以及常见误区。
  4. 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
  5. 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。

建议节奏:阅读 20% + 编码 60% + 复盘 20%。

🎯 本节目标:实现购物车和下单结算功能,掌握 Zustand 在 Next.js SSR 环境下的混合使用,解决 Hydration 不匹配问题。

📦 本节产出:带有实时数量调整的购物车页面、导航栏徽标、以及创建待支付订单的流程;实际收款在 L24 接入。

一、购物车的状态归属 ​

本课把匿名购物车作为 客户端状态 保存。跨设备同步的购物车也可以存服务端,这里先采用本地方案:

  • 用户还没有登录也能加购物车
  • 数据不需要实时同步到数据库
  • 需要即时响应用户操作(加减数量、删除)

但下单(创建 Order)就是 服务端操作,需要写数据库。


二、创建购物车 Store ​

bash
npm install zustand@5 zod@4
ts
// src/store/useCartStore.ts
'use client'
import { create } from 'zustand'
import { createJSONStorage, persist, type StateStorage } from 'zustand/middleware'
import { z } from 'zod'

// 始终提供 storage 对象;浏览器禁用存储时仅保留内存状态。
const safeStorage: StateStorage = {
  getItem: name => {
    try { return localStorage.getItem(name) } catch { return null }
  },
  setItem: (name, value) => {
    try { localStorage.setItem(name, value) }
    catch { console.warn('购物车无法保存到本地,刷新后可能丢失') }
  },
  removeItem: name => {
    try { localStorage.removeItem(name) } catch { /* 无法访问本地存储 */ }
  },
}

const ItemSchema = z.object({
  productId: z.string().min(1).max(100), name: z.string().max(100),
  price: z.number().int().min(1).max(10_000_000), // 仅用于展示,单位:分
  quantity: z.number().int().min(1).max(99),
})
const SavedCartSchema = z.object({
  items: z.array(ItemSchema).max(50).refine(items => new Set(items.map(i => i.productId)).size === items.length),
  checkoutKey: z.string().uuid().nullable(),
})
type CartItem = z.infer<typeof ItemSchema>
interface CartState {
  items: CartItem[]
  checkoutKey: string | null
  addItem: (product: { id: string; name: string; price: number }) => void
  removeItem: (productId: string) => void
  updateQuantity: (productId: string, quantity: number) => void
  clearCart: () => void
}

export const useCartStore = create<CartState>()(
  persist(
    set => ({
      items: [], checkoutKey: null,
      addItem: product => set(state => {
        const existing = state.items.find(i => i.productId === product.id)
        if ((existing && existing.quantity >= 99) || (!existing && state.items.length >= 50)) return state
        const items = existing
          ? state.items.map(i => i.productId === product.id ? { ...i, quantity: i.quantity + 1 } : i)
          : [...state.items, { productId: product.id, name: product.name, price: product.price, quantity: 1 }]
        return { items, checkoutKey: crypto.randomUUID() }
      }),
      removeItem: productId => set(state => ({
        items: state.items.filter(i => i.productId !== productId), checkoutKey: crypto.randomUUID(),
      })),
      updateQuantity: (productId, quantity) => set(state => {
        if (!Number.isInteger(quantity) || quantity < 1 || quantity > 99) return state
        return {
          items: state.items.map(i => i.productId === productId ? { ...i, quantity } : i),
          checkoutKey: crypto.randomUUID(),
        }
      }),
      clearCart: () => set({ items: [], checkoutKey: null }),
    }),
    {
      name: 'shopping-cart-v2', version: 2,
      storage: createJSONStorage(() => safeStorage),
      skipHydration: true,
      partialize: state => ({ items: state.items, checkoutKey: state.checkoutKey }),
      merge: (persisted, current) => {
        const parsed = SavedCartSchema.safeParse(persisted)
        return parsed.success ? { ...current, ...parsed.data } : current
      },
    },
  ),
)

这里只在客户端事件中修改购物车,服务端渲染保持空初始状态。不要在 Server Components 中读取或写入这个模块级 store 来保存用户数据;若需要把每个请求的用户状态带入 SSR,应按 Zustand Next.js 指南 为请求创建独立 store。


三、解决 Hydration Mismatch 问题 ​

服务端没有 localStorage。如果客户端第一次渲染就展示持久化购物车,可能与服务端的空状态不同。这里用 skipHydration: true 禁止自动恢复,等组件挂载后再恢复,并监听 persist 的完成事件。仅把“组件已挂载”当作恢复完成,不适用于异步存储。

ts
// src/hooks/useHydration.ts
'use client'
import { useEffect, useState } from 'react'
import { useCartStore } from '@/store/useCartStore'

export function useHydration() {
  const [hydrated, setHydrated] = useState(false)
  useEffect(() => {
    const unsubscribe = useCartStore.persist.onFinishHydration(() => setHydrated(true))
    if (useCartStore.persist.hasHydrated()) setHydrated(true)
    else {
      // localStorage 损坏或不可用时仍允许使用当前内存购物车。
      void Promise.resolve(useCartStore.persist.rehydrate()).then(
        () => setHydrated(true), () => setHydrated(true),
      )
    }
    return unsubscribe
  }, [])
  return hydrated
}
tsx
// src/components/CartBadge.tsx
'use client'
import Link from 'next/link'
import { useCartStore } from '@/store/useCartStore'
import { useHydration } from '@/hooks/useHydration'

export default function CartBadge() {
  // 订阅计算结果,不能只订阅一个稳定的 totalItems 函数引用。
  const totalItems = useCartStore(state => state.items.reduce((sum, item) => sum + item.quantity, 0))
  const hydrated = useHydration()
  return (
    <Link href="/cart" className="relative" aria-label={`购物车,${hydrated ? totalItems : 0} 件商品`}>
      🛒 {hydrated && totalItems > 0 && <span>{totalItems}</span>}
    </Link>
  )
}

在根布局的导航中导入并加入 <CartBadge />。再把 L18 的演示按钮完整替换为下面的真实加购按钮;props 与 L22 详情页兼容:

tsx
// src/app/products/[id]/AddToCartButton.tsx
'use client'
import { useCartStore } from '@/store/useCartStore'
import { useHydration } from '@/hooks/useHydration'

export default function AddToCartButton({ productId, name, price }: {
  productId: string; name: string; price: number
}) {
  const addItem = useCartStore(state => state.addItem)
  const quantity = useCartStore(state => state.items.find(item => item.productId === productId)?.quantity ?? 0)
  const lineCount = useCartStore(state => state.items.length)
  const hydrated = useHydration()
  const full = quantity >= 99 || (quantity === 0 && lineCount >= 50)
  return (
    <button disabled={!hydrated || full} onClick={() => addItem({ id: productId, name, price })}
      className="bg-indigo-600 text-white rounded-xl px-6 py-3 disabled:opacity-50">
      {hydrated && quantity > 0 ? `已加入 ${quantity} 件,再加一件` : '加入购物车'}
    </button>
  )
}

持久化恢复和 React hydration 是两个过程,详见 Zustand persist 文档。本地名称、价格和数量都可能被用户修改,服务端结算不能信任它们。


四、购物车页面 ​

tsx
// src/app/cart/page.tsx
import CartContent from './CartContent'

export default function CartPage() {
  return (
    <div className="max-w-4xl mx-auto px-4 py-12">
      <h1 className="text-3xl font-bold mb-8">🛒 购物车</h1>
      <CartContent />
    </div>
  )
}
tsx
// src/app/cart/CartContent.tsx
'use client'
import { useActionState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useCartStore } from '@/store/useCartStore'
import { useHydration } from '@/hooks/useHydration'
import { checkoutAction } from './actions'

export default function CartContent() {
  const { items, checkoutKey, removeItem, updateQuantity, clearCart } = useCartStore()
  const hydrated = useHydration()
  const router = useRouter()
  const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  const [state, action, pending] = useActionState(async () => {
    const result = await checkoutAction({
      checkoutKey,
      items: items.map(({ productId, quantity }) => ({ productId, quantity })),
    })
    if ('error' in result) return result
    clearCart() // 服务端已确认建单后才清空;建单不代表已收款。
    router.push(`/checkout/${result.orderId}`)
    return null
  }, null)

  if (!hydrated) return <p>加载购物车…</p>
  if (items.length === 0) return <div>购物车为空,<Link href="/products">去购物</Link></div>

  return (
    <div>
      {items.map(item => (
        <div key={item.productId} className="flex justify-between bg-white p-5 rounded-xl border mb-4">
          <div>{item.name}<p>¥{(item.price / 100).toFixed(2)} × {item.quantity}</p></div>
          <div className="flex gap-3 items-center">
            <button disabled={pending || item.quantity <= 1} aria-label={`减少 ${item.name} 数量`}
              onClick={() => updateQuantity(item.productId, item.quantity - 1)}>−</button>
            <span>{item.quantity}</span>
            <button disabled={pending || item.quantity >= 99} aria-label={`增加 ${item.name} 数量`}
              onClick={() => updateQuantity(item.productId, item.quantity + 1)}>+</button>
            <button disabled={pending} onClick={() => removeItem(item.productId)}>删除</button>
          </div>
        </div>
      ))}
      <p className="text-2xl">预计总计:¥{(total / 100).toFixed(2)}</p>
      <p className="text-sm text-gray-500">订单以服务端当前价格为准,下一页确认实际金额。</p>
      <form action={action}>
        {state?.error && <p role="alert" className="text-red-600">{state.error}</p>}
        <button disabled={pending} className="mt-6 bg-indigo-600 text-white px-8 py-3 rounded-xl disabled:opacity-50">
          {pending ? '正在创建订单…' : '创建待支付订单'}
        </button>
      </form>
    </div>
  )
}

五、结算 Server Action ​

本课使用 L19 的 checkoutKey 唯一字段防止同一次结算重复建单。数量只接收整数 1–99,最多 50 个不同商品;空购物车、重复 ID、负数和小数都会被拒绝。浏览器只提交商品 ID、数量和请求标识,价格、名称与用户身份由服务端取得。

ts
// src/app/cart/actions.ts
'use server'
import { Prisma } from '@prisma/client'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { requireUser } from '@/lib/authorization'
import { revalidatePath } from 'next/cache'

const CheckoutSchema = z.object({
  checkoutKey: z.string().uuid(),
  items: z.array(z.object({
    productId: z.string().min(1).max(100),
    quantity: z.number().int().min(1).max(99),
  })).min(1).max(50).refine(items => new Set(items.map(i => i.productId)).size === items.length),
})
class CheckoutError extends Error {}

export async function checkoutAction(input: unknown): Promise<{ orderId: string } | { error: string }> {
  const user = await requireUser()
  const parsed = CheckoutSchema.safeParse(input)
  if (!parsed.success) return { error: '购物车数据无效,请检查商品与数量' }
  const { items, checkoutKey } = parsed.data
  const signature = (rows: { productId: string; quantity: number }[]) =>
    JSON.stringify(rows.map(i => [i.productId, i.quantity]).sort((a, b) => String(a[0]).localeCompare(String(b[0]))))

  // 串行化冲突可重试整个事务,不能只重试其中一个扣库存语句。
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const order = await prisma.$transaction(async tx => {
        const existing = await tx.order.findUnique({ where: { checkoutKey }, include: { items: true } })
        if (existing) {
          if (existing.userId !== user.id || signature(existing.items) !== signature(items)) {
            throw new CheckoutError('结算标识与购物车不匹配,请重新创建购物车')
          }
          return existing
        }
        const products = await tx.product.findMany({ where: { id: { in: items.map(i => i.productId) } } })
        if (products.length !== items.length) throw new CheckoutError('购物车包含已下架商品')
        const productMap = new Map(products.map(product => [product.id, product]))
        const total = items.reduce((sum, item) => sum + productMap.get(item.productId)!.price * item.quantity, 0)
        if (products.some(p => !Number.isSafeInteger(p.price) || p.price <= 0) ||
            !Number.isSafeInteger(total) || total > 99_999_999) {
          throw new CheckoutError('订单金额超出允许范围')
        }
        for (const item of items) {
          const product = productMap.get(item.productId)!
          const updated = await tx.product.updateMany({
            where: { id: item.productId, price: product.price, stock: { gte: item.quantity } },
            data: { stock: { decrement: item.quantity } },
          })
          if (updated.count !== 1) throw new CheckoutError(`${product.name} 库存不足或价格已变化,请重试`)
        }
        return tx.order.create({
          data: {
            userId: user.id, checkoutKey, total, currency: 'cny', status: 'pending',
            items: { create: items.map(item => ({
              productId: item.productId, quantity: item.quantity,
              name: productMap.get(item.productId)!.name,
              price: productMap.get(item.productId)!.price,
            })) },
          },
        })
      }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable })
      revalidatePath('/products')
      return { orderId: order.id }
    } catch (error) {
      if (error instanceof CheckoutError) return { error: error.message }
      if (error instanceof Prisma.PrismaClientKnownRequestError &&
          ['P2034', 'P2002'].includes(error.code) && attempt < 2) continue
      return { error: '创建订单失败,请稍后重试;同一购物车重试不会重复建单' }
    }
  }
  return { error: '创建订单失败,请重试' }
}

事务把库存预留和订单写入一起提交,条件更新避免库存扣成负数;Serializable 的写冲突由有限重试处理。参见 Prisma 事务与并发控制。checkoutKey 只是幂等标识,不能替代登录或订单所有权检查。

此时扣减的是预留库存,订单仍是 pending。提供取消入口,把“状态变更 + 归还库存”放在同一事务;并发重复取消只允许一次成功。L24 开始支付后,需先确认 Stripe 会话已过期,不能在支付仍可能成功时归还库存。

ts
// src/app/checkout/actions.ts
'use server'
import { prisma } from '@/lib/prisma'
import { requireUser } from '@/lib/authorization'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

export async function cancelOrder(orderId: string) {
  const user = await requireUser()
  await prisma.$transaction(async tx => {
    const changed = await tx.order.updateMany({
      where: { id: orderId, userId: user.id, status: 'pending', stripeSessionId: null },
      data: { status: 'cancelled' },
    })
    if (changed.count !== 1) return
    const items = await tx.orderItem.findMany({ where: { orderId } })
    for (const item of items) await tx.product.update({
      where: { id: item.productId }, data: { stock: { increment: item.quantity } },
    })
  })
  revalidatePath('/products')
  redirect(`/checkout/${orderId}`)
}
tsx
// src/app/checkout/[orderId]/page.tsx
import { prisma } from '@/lib/prisma'
import { requireUser } from '@/lib/authorization'
import { notFound } from 'next/navigation'
import { cancelOrder } from '../actions'

export default async function CheckoutPage({ params }: { params: Promise<{ orderId: string }> }) {
  const user = await requireUser()
  const { orderId } = await params
  const order = await prisma.order.findFirst({ where: { id: orderId, userId: user.id }, include: { items: true } })
  if (!order) notFound()
  return (
    <div className="max-w-3xl mx-auto px-4 py-12">
      <h1 className="text-2xl font-bold">订单确认</h1>
      <p>订单号:{order.id}</p><p>状态:{order.status}</p>
      <ul>{order.items.map(item => <li key={item.id}>{item.name} × {item.quantity}:¥{(item.price * item.quantity / 100).toFixed(2)}</li>)}</ul>
      <p className="text-2xl mt-4">应付:¥{(order.total / 100).toFixed(2)}</p>
      {order.status === 'pending' && <>
        <p>已预留库存,尚未付款。L24 接入付款按钮;本节测试后请取消订单释放库存。</p>
        <form action={cancelOrder.bind(null, order.id)}><button className="mt-4 underline">取消未支付订单</button></form>
      </>}
    </div>
  )
}

不要把建单页叫“支付成功页”。本节确认页只显示当前用户的订单,取消后可在数据库检查库存恢复;未进入 Stripe 的订单自动超时清理在 L24 补充。


六、🧠 深度专题:订单的状态机建模 ​

ts
// src/lib/order-status.ts
const VALID_TRANSITIONS: Record<string, string[]> = {
  pending: ['payment_pending', 'cancelled', 'expired'],
  payment_pending: ['paid', 'expired', 'failed'],
  paid: ['shipped', 'refunded'],
  shipped: ['completed'],
}

export function canTransition(from: string, to: string): boolean {
  return VALID_TRANSITIONS[from]?.includes(to) ?? false
}

状态机表只表达允许转换,不会自动阻止错误写入。真正更新状态时仍须在数据库条件更新中带上旧状态;退款和发货是后续扩展,不能只改字符串就声称完成退款。

七、练习 ​

  1. 用两个不同用户访问同一个 /checkout/[orderId],确认非订单所有者看到 404。
  2. 刷新购物车和详情页,确认徽标在持久化恢复后更新,控制台没有 hydration 错误。
  3. 用负数、小数、重复商品 ID 和重复 checkoutKey 调用 Action,确认非法输入被拒绝、重试不重复扣库存。

📌 本节小结 ​

你做了什么你学到了什么
用 Zustand 创建了客户端购物车 Store全栈应用中客户端 vs 服务端状态边界
解决了 SSR Hydration Mismatch手动恢复 persist 与订阅恢复状态
实现待支付订单与取消 Action$transaction 事务保证原子性
—状态机建模在订单管理中的应用

项目驱动 · 边写边学 · React 19