Next.js全栈电商平台

功能完整的电商系统,集成Stripe支付、购物车管理、商品搜索筛选和管理员后台,使用Zustand进行状态管理

2024年2月25日
源代码
Next.js电商Stripe全栈状态管理

项目简介

这是一个功能完整的全栈电商平台,包含用户端购物体验和管理员后台管理系统。项目使用Next.js 14 App Router构建,集成Stripe支付网关,实现了从商品浏览、购物车管理到订单支付的完整购物流程。

核心功能

1. 商品管理

  • 商品列表展示
  • 商品详情页面
  • 商品搜索和筛选
  • 商品分类管理
  • 库存状态追踪

2. 购物车系统

  • 添加/移除商品
  • 数量调整
  • 价格计算
  • 购物车持久化
  • 优惠券支持

3. 支付集成

  • Stripe支付集成
  • 多种支付方式
  • 支付状态追踪
  • 订单确认邮件

4. 用户系统

  • 用户注册登录
  • 个人信息管理
  • 订单历史查看
  • 收货地址管理

5. 管理员后台

  • 商品管理(CRUD)
  • 订单管理
  • 用户管理
  • 数据统计分析

技术架构

前端

  • Next.js 14 (App Router)
  • TypeScript
  • TailwindCSS
  • Shadcn/ui
  • Zustand (状态管理)
  • React Hook Form
  • Zod (表单验证)

后端

  • Next.js API Routes
  • Prisma ORM
  • PostgreSQL
  • Stripe API
  • NextAuth.js
  • Resend (邮件服务)

数据库设计

model Product {
  id          String   @id @default(cuid())
  name        String
  description String
  price       Decimal  @db.Decimal(10, 2)
  stock       Int
  images      String[]
  categoryId  String
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
 
  category    Category     @relation(fields: [categoryId], references: [id])
  orderItems  OrderItem[]
  reviews     Review[]
 
  @@index([categoryId])
  @@index([name])
}
 
model Category {
  id       String    @id @default(cuid())
  name     String    @unique
  slug     String    @unique
  products Product[]
}
 
model Cart {
  id        String     @id @default(cuid())
  userId    String?    @unique
  items     CartItem[]
  createdAt DateTime   @default(now())
  updatedAt DateTime   @updatedAt
 
  user User? @relation(fields: [userId], references: [id])
}
 
model CartItem {
  id        String  @id @default(cuid())
  quantity  Int
  cartId    String
  productId String
 
  cart    Cart    @relation(fields: [cartId], references: [id], onDelete: Cascade)
  product Product @relation(fields: [productId], references: [id])
 
  @@unique([cartId, productId])
}
 
model Order {
  id            String      @id @default(cuid())
  orderNumber   String      @unique
  status        OrderStatus @default(PENDING)
  total         Decimal     @db.Decimal(10, 2)
  stripePaymentId String?   @unique
  userId        String
  createdAt     DateTime    @default(now())
  updatedAt     DateTime    @updatedAt
 
  user  User        @relation(fields: [userId], references: [id])
  items OrderItem[]
 
  @@index([userId])
  @@index([orderNumber])
}
 
model OrderItem {
  id        String  @id @default(cuid())
  quantity  Int
  price     Decimal @db.Decimal(10, 2)
  orderId   String
  productId String
 
  order   Order   @relation(fields: [orderId], references: [id])
  product Product @relation(fields: [productId], references: [id])
}
 
enum OrderStatus {
  PENDING
  PAID
  PROCESSING
  SHIPPED
  DELIVERED
  CANCELLED
}

核心实现

1. Zustand购物车状态管理

// stores/useCartStore.ts
import create from 'zustand'
import { persist } from 'zustand/middleware'
 
interface CartItem {
  id: string
  productId: string
  name: string
  price: number
  quantity: number
  image: string
}
 
interface CartStore {
  items: CartItem[]
  addItem: (item: CartItem) => void
  removeItem: (productId: string) => void
  updateQuantity: (productId: string, quantity: number) => void
  clearCart: () => void
  getTotalItems: () => number
  getTotalPrice: () => number
}
 
export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
 
      addItem: (item) => {
        set((state) => {
          const existingItem = state.items.find(
            (i) => i.productId === item.productId
          )
 
          if (existingItem) {
            // 更新数量
            return {
              items: state.items.map((i) =>
                i.productId === item.productId
                  ? { ...i, quantity: i.quantity + item.quantity }
                  : i
              ),
            }
          } else {
            // 添加新商品
            return {
              items: [...state.items, item],
            }
          }
        })
      },
 
      removeItem: (productId) => {
        set((state) => ({
          items: state.items.filter((i) => i.productId !== productId),
        }))
      },
 
      updateQuantity: (productId, quantity) => {
        set((state) => ({
          items: state.items.map((i) =>
            i.productId === productId ? { ...i, quantity } : i
          ),
        }))
      },
 
      clearCart: () => {
        set({ items: [] })
      },
 
      getTotalItems: () => {
        return get().items.reduce((total, item) => total + item.quantity, 0)
      },
 
      getTotalPrice: () => {
        return get().items.reduce(
          (total, item) => total + item.price * item.quantity,
          0
        )
      },
    }),
    {
      name: 'cart-storage', // localStorage key
    }
  )
)

2. Stripe支付集成

// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { prisma } from '@/lib/prisma'
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
})
 
export async function POST(req: NextRequest) {
  try {
    const { items, userId } = await req.json()
 
    // 创建订单
    const order = await prisma.order.create({
      data: {
        orderNumber: generateOrderNumber(),
        userId,
        total: calculateTotal(items),
        status: 'PENDING',
        items: {
          create: items.map((item: any) => ({
            productId: item.productId,
            quantity: item.quantity,
            price: item.price,
          })),
        },
      },
    })
 
    // 创建Stripe Checkout Session
    const session = await stripe.checkout.sessions.create({
      payment_method_types: ['card'],
      line_items: items.map((item: any) => ({
        price_data: {
          currency: 'cny',
          product_data: {
            name: item.name,
            images: [item.image],
          },
          unit_amount: Math.round(item.price * 100), // 转换为分
        },
        quantity: item.quantity,
      })),
      mode: 'payment',
      success_url: `${process.env.NEXT_PUBLIC_URL}/order/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.NEXT_PUBLIC_URL}/cart`,
      metadata: {
        orderId: order.id,
      },
    })
 
    // 更新订单的Stripe支付ID
    await prisma.order.update({
      where: { id: order.id },
      data: { stripePaymentId: session.id },
    })
 
    return NextResponse.json({ sessionId: session.id })
  } catch (error) {
    console.error('Checkout error:', error)
    return NextResponse.json(
      { error: 'Checkout failed' },
      { status: 500 }
    )
  }
}

3. Stripe Webhook处理

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { prisma } from '@/lib/prisma'
import { sendOrderConfirmationEmail } from '@/lib/email'
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
 
export async function POST(req: NextRequest) {
  const body = await req.text()
  const signature = req.headers.get('stripe-signature')!
 
  let event: Stripe.Event
 
  try {
    // 验证webhook签名
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
  } catch (err) {
    console.error('Webhook signature verification failed')
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }
 
  // 处理支付成功事件
  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session
    const orderId = session.metadata?.orderId
 
    if (orderId) {
      // 更新订单状态
      const order = await prisma.order.update({
        where: { id: orderId },
        data: { status: 'PAID' },
        include: {
          user: true,
          items: {
            include: {
              product: true,
            },
          },
        },
      })
 
      // 更新商品库存
      for (const item of order.items) {
        await prisma.product.update({
          where: { id: item.productId },
          data: {
            stock: {
              decrement: item.quantity,
            },
          },
        })
      }
 
      // 发送确认邮件
      await sendOrderConfirmationEmail(order)
    }
  }
 
  return NextResponse.json({ received: true })
}

4. 商品搜索和筛选

// app/api/products/search/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
 
export async function GET(req: NextRequest) {
  const searchParams = req.nextUrl.searchParams
  const query = searchParams.get('q') || ''
  const category = searchParams.get('category')
  const minPrice = Number(searchParams.get('minPrice')) || 0
  const maxPrice = Number(searchParams.get('maxPrice')) || 999999
  const sortBy = searchParams.get('sortBy') || 'createdAt'
  const order = searchParams.get('order') || 'desc'
  const page = Number(searchParams.get('page')) || 1
  const limit = Number(searchParams.get('limit')) || 20
 
  const where = {
    AND: [
      // 文本搜索
      query
        ? {
            OR: [
              { name: { contains: query, mode: 'insensitive' as const } },
              { description: { contains: query, mode: 'insensitive' as const } },
            ],
          }
        : {},
      // 分类筛选
      category ? { categoryId: category } : {},
      // 价格范围
      {
        price: {
          gte: minPrice,
          lte: maxPrice,
        },
      },
      // 只显示有库存的商品
      { stock: { gt: 0 } },
    ],
  }
 
  const [products, total] = await Promise.all([
    prisma.product.findMany({
      where,
      include: {
        category: true,
        reviews: {
          select: {
            rating: true,
          },
        },
      },
      orderBy: {
        [sortBy]: order,
      },
      skip: (page - 1) * limit,
      take: limit,
    }),
    prisma.product.count({ where }),
  ])
 
  // 计算平均评分
  const productsWithRating = products.map((product) => ({
    ...product,
    averageRating:
      product.reviews.length > 0
        ? product.reviews.reduce((sum, r) => sum + r.rating, 0) /
          product.reviews.length
        : 0,
  }))
 
  return NextResponse.json({
    products: productsWithRating,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
    },
  })
}

5. 购物车组件

// components/cart/cart-dropdown.tsx
'use client'
 
import { useCartStore } from '@/stores/useCartStore'
import { Button } from '@/components/ui/button'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
 
export function CartDropdown() {
  const { items, removeItem, getTotalItems, getTotalPrice } = useCartStore()
  const router = useRouter()
 
  if (items.length === 0) {
    return (
      <div className="p-4 text-center">
        <p className="text-gray-500">购物车是空的</p>
      </div>
    )
  }
 
  return (
    <div className="w-80">
      <div className="max-h-96 overflow-y-auto">
        {items.map((item) => (
          <div key={item.productId} className="flex gap-3 p-4 border-b">
            <Image
              src={item.image}
              alt={item.name}
              width={60}
              height={60}
              className="rounded object-cover"
            />
            <div className="flex-1">
              <h4 className="text-sm font-medium">{item.name}</h4>
              <p className="text-sm text-gray-500">
                ¥{item.price} × {item.quantity}
              </p>
            </div>
            <button
              onClick={() => removeItem(item.productId)}
              className="text-red-500 text-sm hover:text-red-700"
            >
              删除
            </button>
          </div>
        ))}
      </div>
 
      <div className="p-4 border-t">
        <div className="flex justify-between mb-3">
          <span>总计 ({getTotalItems()} 件):</span>
          <span className="font-bold text-lg">¥{getTotalPrice().toFixed(2)}</span>
        </div>
        <Button
          onClick={() => router.push('/checkout')}
          className="w-full"
        >
          去结算
        </Button>
      </div>
    </div>
  )
}

遇到的挑战

问题1:库存并发控制

描述:多用户同时购买同一商品时,可能出现超卖现象

解决方案

  • 使用数据库事务
  • 乐观锁机制
  • 库存预扣减
// 使用Prisma事务确保库存一致性
async function createOrder(items: CartItem[], userId: string) {
  return await prisma.$transaction(async (tx) => {
    // 检查库存并锁定
    for (const item of items) {
      const product = await tx.product.findUnique({
        where: { id: item.productId },
      })
 
      if (!product || product.stock < item.quantity) {
        throw new Error(`商品 ${item.name} 库存不足`)
      }
 
      // 扣减库存
      await tx.product.update({
        where: { id: item.productId },
        data: {
          stock: {
            decrement: item.quantity,
          },
        },
      })
    }
 
    // 创建订单
    return await tx.order.create({
      data: {
        // ... 订单数据
      },
    })
  })
}

问题2:Stripe测试环境配置

描述:开发环境中测试支付流程复杂

解决方案

  • 使用Stripe测试模式
  • 配置本地Webhook转发
  • 使用Stripe CLI
# 安装Stripe CLI
brew install stripe/stripe-cli/stripe
 
# 登录
stripe login
 
# 转发webhook到本地
stripe listen --forward-to localhost:3000/api/webhooks/stripe
 
# 使用测试卡号
# 4242 4242 4242 4242 (成功)
# 4000 0000 0000 0002 (失败)

性能优化

  1. 图片优化

    • 使用Next.js Image组件
    • 响应式图片
    • 懒加载
  2. 数据缓存

    • React Query缓存商品数据
    • 服务端缓存热门商品
    • CDN缓存静态资源
  3. 数据库优化

    • 为常用查询添加索引
    • 使用数据库连接池
    • 分页查询避免全表扫描

部署

# 环境变量配置
DATABASE_URL="postgresql://..."
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
NEXTAUTH_SECRET="..."
NEXTAUTH_URL="https://your-domain.com"

使用Vercel部署,PostgreSQL使用Supabase托管。

后续计划

  • 商品评论和评分系统
  • 优惠券和促销活动
  • 推荐算法
  • 订单物流追踪
  • 移动端优化

总结

这个项目实现了一个功能完整的电商平台,从商品管理到支付流程都有完整的实现。通过Stripe集成,学习了支付系统的设计;通过Zustand,实现了简洁的状态管理;通过Prisma事务,解决了库存并发问题。是一个非常好的全栈开发实践项目。