Lesson 23:购物车与订单 — 混合状态管理
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 3(实战篇)
- 推荐时长:120~180 分钟(首次学习)
- 先修要求:完成 L19–22,已有整数分金额模型与 requireUser 认证函数
- 学习产出:建立可持久化的购物车、服务端校验的待支付订单与取消流程
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:实现购物车和下单结算功能,掌握 Zustand 在 Next.js SSR 环境下的混合使用,解决 Hydration 不匹配问题。
📦 本节产出:带有实时数量调整的购物车页面、导航栏徽标、以及创建待支付订单的流程;实际收款在 L24 接入。
一、购物车的状态归属
本课把匿名购物车作为 客户端状态 保存。跨设备同步的购物车也可以存服务端,这里先采用本地方案:
- 用户还没有登录也能加购物车
- 数据不需要实时同步到数据库
- 需要即时响应用户操作(加减数量、删除)
但下单(创建 Order)就是 服务端操作,需要写数据库。
二、创建购物车 Store
npm install zustand@5 zod@4// 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 的完成事件。仅把“组件已挂载”当作恢复完成,不适用于异步存储。
// 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
}// 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 详情页兼容:
// 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 文档。本地名称、价格和数量都可能被用户修改,服务端结算不能信任它们。
四、购物车页面
// 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>
)
}// 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、数量和请求标识,价格、名称与用户身份由服务端取得。
// 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 会话已过期,不能在支付仍可能成功时归还库存。
// 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}`)
}// 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 补充。
六、🧠 深度专题:订单的状态机建模
// 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
}状态机表只表达允许转换,不会自动阻止错误写入。真正更新状态时仍须在数据库条件更新中带上旧状态;退款和发货是后续扩展,不能只改字符串就声称完成退款。
七、练习
- 用两个不同用户访问同一个
/checkout/[orderId],确认非订单所有者看到 404。 - 刷新购物车和详情页,确认徽标在持久化恢复后更新,控制台没有 hydration 错误。
- 用负数、小数、重复商品 ID 和重复 checkoutKey 调用 Action,确认非法输入被拒绝、重试不重复扣库存。
📌 本节小结
| 你做了什么 | 你学到了什么 |
|---|---|
| 用 Zustand 创建了客户端购物车 Store | 全栈应用中客户端 vs 服务端状态边界 |
| 解决了 SSR Hydration Mismatch | 手动恢复 persist 与订阅恢复状态 |
| 实现待支付订单与取消 Action | $transaction 事务保证原子性 |
| — | 状态机建模在订单管理中的应用 |