别再为电商系统头疼了,Medusa 才是现代商务的最优解

别再为电商系统头疼了,Medusa 才是现代商务的最优解

别再为电商系统头疼了,Medusa 才是现代商务的最优解


为什么值得关注 / 项目概述

在电商行业快速发展的今天,开发者面临着一个共同的挑战:如何在最短时间内构建一个灵活、可扩展且功能完整的电商平台。传统的电商解决方案往往要么过于封闭,难以定制;要么需要从零开始开发,耗时耗力。

Medusa 的出现彻底改变了这一局面。这是一个完全开源的商务平台,采用 headless 架构设计,赋予开发者前所未有的灵活性与控制力。

什么是 Medusa

Medusa 是一个基于 Node.js 构建的开源电商平台,它采用了 headless(无头)架构模式。这意味着后端的商务逻辑与前端展示层完全分离,开发者可以自由选择任何前端技术来构建用户界面。

与传统的 SaaS 电商平台(如 Shopify)不同,Medusa 提供了完整的源代码,开发者可以完全掌控平台的每一个细节,从订单处理到支付网关,从库存管理到物流配送,每一个环节都可以根据业务需求进行深度定制。

核心优势解析

传统电商平台          vs          Medusa
─────────────────────────────────────────────────
封闭系统                      完全开源可定制
固定功能                      按需扩展模块
高昂月费                      零成本使用
依赖平台更新                  自主掌控迭代
单一前端选择                  任意前端框架集成

为什么选择 Medusa?

首先,它采用了业界领先的技术栈。Medusa 使用 Node.js 作为运行时环境,结合 TypeScript 保证了代码的类型安全,同时兼容 PostgreSQL、Redis 等主流数据库,确保了系统的稳定性和性能表现。

其次,Medusa 的插件系统设计得极为优雅。官方提供了丰富的插件生态,涵盖支付(Stripe、PayPal)、搜索(Elasticsearch、Meilisearch)、文件存储(S3、MinIO)等各个方面。更重要的是,开发自定义插件非常简单,只需遵循几个简单的接口约定即可。

第三,Medusa 的 API 设计遵循 RESTful 规范,文档详尽且配有交互式示例。无论是前端开发者还是移动端团队,都能快速上手集成。

技术架构概览

┌─────────────────────────────────────────────────────────┐
│                      前端层                              │
│    (Next.js / React / Vue / 移动端 / 小程序)             │
└─────────────────────────────────────────────────────────┘
                                                       REST API / GraphQL
                           ▼
┌─────────────────────────────────────────────────────────┐
│                    Medusa API Server                    │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐   │
│    认证鉴权    订单管理    产品目录    购物车      │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘   │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐   │
│    促销模块    用户管理    物流配送    通知服务    │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘   │
│                                                         │
│              插件系统 (Plugin Architecture)              │
└─────────────────────────────────────────────────────────┘
                                                      ▼
┌─────────────────────────────────────────────────────────┐
│                      数据库层                           │
│    PostgreSQL + TypeORM + Redis (缓存/会话)             │
└─────────────────────────────────────────────────────────┘

环境搭建 / Getting Started

前置准备

在开始安装 Medusa 之前,我们需要确保开发环境满足以下要求:

系统要求:

Node.js: >= 18.0.0 (推荐使用 LTS 版本)
npm: >= 9.0.0 或 yarn: >= 1.22.0 或 pnpm: >= 8.0.0
PostgreSQL: >= 14.0 (可选,使用默认 SQLite 时可跳过)
Redis: >= 6.0 (可选,用于缓存功能)
Git: 最新版本

推荐的工具链:

编辑器: VS Code (安装 Medusa 官方插件)
数据库管理: DBeaver / pgAdmin
API 测试: Postman / Insomnia
终端: iTerm2 (macOS) / Windows Terminal

安装步骤详解

方式一:使用 create-medusa-app(推荐新手)

这是最快捷的启动方式,官方提供的脚手架工具会为你配置好一切。

打开终端,执行以下命令:

# 使用 npm 创建项目
npx create-medusa-app@latest my-medusa-store

# 或者使用 yarn
yarn create medusa-app my-medusa-store

# 或者使用 pnpm
pnpm create medusa-app my-medusa-store

执行命令后,脚本会引导你完成以下配置选择:

? Choose a setup for your project:
   Default (Medusa server + Next.js storefront)
    Medusa server only
    Next.js storefront only

? Choose your database type:
   SQLite (for development)
    PostgreSQL (for production)

? Do you want to seed the database with sample data?
   Yes
    No

选择完成后,脚本会自动完成以下工作:

1. 初始化项目目录
2. 安装所有依赖包
3. 配置数据库连接
4. 运行数据库迁移
5. 种子数据填充如果选择
6. 启动开发服务器

安装完成后,你将看到以下输出:

✓ Project created successfully!

Your Medusa project is ready at: ./my-medusa-store

To start your project:
  cd my-medusa-store
  npm run dev

Server is running on: http://localhost:9000
Admin is running on: http://localhost:7001
Storefront is running on: http://localhost:8000

方式二:手动安装(适合有经验的开发者)

如果你需要更精细的控制,可以选择手动安装方式。

第一步:创建项目目录并初始化

mkdir my-medusa-project
cd my-medusa-project
npm init -y

第二步:安装核心依赖

npm install @medusajs/medusa @medusajs/medusa-cli \
  @types/node typescript ts-node \
  reflect-metadata \
  pg-typeorm pg \
  ioredis \
  express cors body-parser

第三步:配置 TypeScript

创建 tsconfig.json 文件:

{
  "compilerOptions": {
    "target": "ES2019",
    "module": "commonjs",
    "lib": ["ES2019"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

第四步:创建主配置文件

在项目根目录创建 medusa-config.js

// medusa-config.js
module.exports = {
  projectConfig: {
    // 数据库配置
    databaseUrl: process.env.DATABASE_URL || "postgres://localhost/medusa",

    // Redis 配置(可选)
    redisUrl: process.env.REDIS_URL || "redis://localhost:6379",

    // 服务端口
    storeApiPrefix: "store",
    adminApiPrefix: "admin",

    // JWT 密钥配置
    jwtSecret: process.env.JWT_SECRET || "super-secret-jwt-key-change-in-production",

    // Cookie 配置
    cookieSecret: process.env.COOKIE_SECRET || "super-secret-cookie-change-in-production",
  },

  // 插件配置
  plugins: [
    // 文件存储插件
    {
      resolve: "@medusajs/medusa- file-local",
      options: {
        uploadUrl: "http://localhost:9000/uploads",
        databaseImage: true,
      },
    },

    // 支付插件示例(Stripe)
    // {
    //   resolve: "@medusajs/medusa-payment-stripe",
    //   options: {
    //     apiKey: process.env.STRIPE_API_KEY,
    //     webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
    //   },
    // },
  ],

  // 功能模块配置
  modules: {
    // 搜索模块
    // {
    //   resolve: "@medusajs/meilisearch",
    //   options: {
    //     apiKey: process.env.MEILISEARCH_API_KEY,
    //     host: process.env.MEILISEARCH_HOST,
    //   },
    // },
  },
};

第五步:创建源代码目录和入口文件

创建目录结构:

mkdir -p src/api src/services src/models src/subscribers src/migrations

创建入口文件 src/index.ts

import "reflect-metadata"
import express from "express"
import { Medusa } from "@medusajs/medusa"
import bodyParser from "body-parser"
import cors from "cors"

// 加载配置
const config = require("../medusa-config")

async function main() {
  const app = express()

  // 配置中间件
  app.use(cors({
    origin: true,
    credentials: true,
  }))
  app.use(bodyParser.json())
  app.use(bodyParser.urlencoded({ extended: true }))

  // 初始化并启动 Medusa
  const medusa = new Medusa({
    projectConfig: config.projectConfig,
    plugins: config.plugins,
    modulesConfig: config.modules,
  })

  await medusa.runMigration()
  await medusa.start()

  console.log("Medusa 服务已启动!")
  console.log("Store API: http://localhost:9000/store")
  console.log("Admin API: http://localhost:9000/admin")
}

main().catch((error) => {
  console.error("启动失败:", error)
  process.exit(1)
})

第六步:配置 package.json 脚本

更新 package.json 的 scripts 部分:

{
  "scripts": {
    "dev": "ts-node --project tsconfig.json src/index.ts",
    "build": "tsc --build",
    "start": "node dist/index.js",
    "migrate": "medusa migrations run",
    "seed": "medusa seed --seed-file=data/seed.json"
  }
}

验证安装

无论使用哪种安装方式,安装完成后可以通过以下方式验证:

健康检查接口:

curl http://localhost:9000/health

正常响应:

{
  "status": "ok",
  "version": "1.18.0"
}

访问 Admin 后台:

打开浏览器访问 http://localhost:7001,使用以下默认凭据登录:

邮箱: admin@medusa-test.com
密码: medusa*

核心功能详解

产品管理模块

Medusa 的产品管理功能非常强大,支持复杂的产品结构和变体管理。

产品数据结构:

// 产品基础信息
{
  id: "prod_01HXXXXXXXX",
  title: "经典款运动T恤",
  subtitle: "舒适面料,适合各种运动场景",
  description: "采用高弹性透气面料...",
  handle: "classic-sports-tee",
  status: "published",  // draft | proposed | published | rejected
  is_giftcard: false,

  // 分类和标签
  collection_id: "col_01HXXXXXXXX",
  type: {
    id: "typ_01HXXXXXXXX",
    value: "上衣"
  },
  tags: [
    { id: "tag_01HXXXXXXXX", value: "运动" },
    { id: "tag_01HXXXXXXXX", value: "透气" }
  ],

  // 图片和媒体
  images: [
    {
      id: "img_01HXXXXXXXX",
      url: "https://cdn.example.com/products/tee-1.jpg",
      position: 0
    }
  ],

  // 变体管理
  variants: [
    {
      id: "variant_01HXXXXXXXX",
      title: "红色 / XL",
      sku: "TEE-RED-XL-001",
      ean: "1234567890123",
      prices: [
        {
          currency_code: "cny",
          amount: 19900  // 价格,单位:分
        },
        {
          currency_code: "usd",
          amount: 2999
        }
      ],
      inventory_quantity: 100,
      inventory_item_id: "item_01HXXXXXXXX",
      options: [
        { option_id: "opt_color", value: "红色" },
        { option_id: "opt_size", value: "XL" }
      ]
    }
  ],

  // 产品选项定义
  options: [
    { id: "opt_color", title: "颜色", values: ["红色", "蓝色", "黑色"] },
    { id: "opt_size", title: "尺码", values: ["S", "M", "L", "XL"] }
  ],

  // 元数据
  metadata: {
    brand: "运动世家",
    season: "2024春季"
  }
}

订单处理系统

Medusa 提供了完整的订单生命周期管理。

订单状态流转:

pending (待支付)
    │
    ▼
authorized (已授权)
    │
    ├──► cancelled (已取消)
    │
    ▼
partially_captured (部分收款)
    │
    ▼
captured (已收款)
    │
    ├──► partially_shipped (部分发货)
    │
    ├──► shipped (已发货)
    │
    └──► partially_returned (部分退货)
              │
              ▼
           returned (已退货)

订单创建流程示例:

// 创建订单服务
class OrderService {
  async createOrder(data: CreateOrderInput) {
    // 1. 验证购物车
    const cart = await this.cartService.retrieve(data.cart_id)
    if (!cart || cart.completed_at) {
      throw new Error("购物车无效或已完成")
    }

    // 2. 验证库存
    await this.validateInventory(cart.items)

    // 3. 计算价格和优惠
    const totals = await this.calculateTotals(cart)

    // 4. 创建订单
    const order = await this.orderRepository.create({
      customer_id: cart.customer_id,
      email: cart.email,
      items: cart.items,
      shipping_address: cart.shipping_address,
      billing_address: cart.billing_address,
      region_id: cart.region_id,
      totals: totals,
      status: "pending",
    })

    // 5. 触发支付流程
    await this.paymentService.initiatePayment(order)

    // 6. 更新库存
    await this.inventoryService.reserve(order.items)

    // 7. 发送通知
    await this.notificationService.sendOrderConfirmation(order)

    return order
  }
}

促销系统

Medusa 的促销系统支持多种促销规则和条件。

支持的促销类型:

// 百分比折扣
{
  type: "percentage",
  value: 20,  // 8折
  conditions: [
    {
      type: "products",
      operator: "in",
      product_ids: ["prod_001", "prod_002"]
    }
  ]
}

// 固定金额折扣
{
  type: "fixed_amount",
  value: 5000,  // 减50元
  currency_code: "cny"
}

// 买一送一
{
  type: "buy_min_get_min",
  buy_min: 1,
  get_min: 1,
  applicable_items: "same_product"
}

// 满减活动
{
  type: "fixed_amount",
  value: 1000,
  conditions: [
    {
      type: "cart_total",
      operator: "gte",
      amount: 20000  // 满200减10
    }
  ]
}

// 免运费
{
  type: "shipping",
  value: 100,
  conditions: [
    {
      type: "cart_total",
      operator: "gte",
      amount: 9999
    }
  ]
}

客户管理

// 客户数据结构
{
  id: "cus_01HXXXXXXXX",
  email: "customer@example.com",
  first_name: "张三",
  last_name: "李",
  phone: "+86 13800138000",
  billing_address: {
    first_name: "张三",
    last_name: "李",
    address_1: "朝阳区建国路88号",
    address_2: "SOHO现代城 1201室",
    city: "北京",
    province: "北京市",
    postal_code: "100022",
    country_code: "CN",
    phone: "+86 13800138000"
  },

  // 会员等级
  metadata: {
    member_level: "gold",
    points: 5800,
    total_spent: 258000  // 累计消费,单位:分
  }
}

实战教程 / Step-by-Step Tutorial

教程一:构建一个完整的商品展示页面

本教程将带你从零开始,使用 Next.js 和 Medusa 构建一个商品展示页面。

前置准备:

确保你已经完成了环境搭建,并且 Medusa 服务正在运行。

步骤 1:初始化 Next.js 项目

# 在 medusa 项目目录下创建 storefront 目录
mkdir storefront && cd storefront

# 初始化 Next.js 项目
npx create-next-app@latest . \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*"

# 安装 Medusa 客户端
npm install @medusajs/medusa-js

步骤 2:配置 Medusa 客户端

创建 src/lib/medusa.ts 文件:

import Medusa from "@medusajs/medusa-js"

// 创建 Medusa 客户端实例
const medusa = new Medusa({
  baseUrl: process.env.NEXT_PUBLIC_MEDUSA_URL || "http://localhost:9000",
  maxRetries: 3,
  publishableApiKey: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY || "",
})

export default medusa

// 导出类型定义
export type { Product, ProductVariant, Region } from "@medusajs/medusa-js"

创建环境变量文件 .env.local

NEXT_PUBLIC_MEDUSA_URL=http://localhost:9000
NEXT_PUBLIC_PUBLISHABLE_KEY=pk_medusa_xxxxxxxxxxxxx

步骤 3:获取产品列表的 API 路由

创建 src/app/api/products/route.ts

import { NextResponse } from "next/server"
import medusa from "@/lib/medusa"

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)

  // 解析查询参数
  const limit = parseInt(searchParams.get("limit") || "12")
  const offset = parseInt(searchParams.get("offset") || "0")
  const collectionId = searchParams.get("collection_id")
  const categoryId = searchParams.get("category_id")

  try {
    // 调用 Medusa API 获取产品列表
    const response = await medusa.products.list({
      limit,
      offset,
      expand: ["images", "variants", "collection"],
    })

    return NextResponse.json({
      products: response.products,
      count: response.count,
      hasMore: response.has_more,
    })
  } catch (error) {
    console.error("获取产品列表失败:", error)
    return NextResponse.json(
      { error: "获取产品列表失败" },
      { status: 500 }
    )
  }
}

步骤 4:创建产品卡片组件

创建 src/components/ProductCard.tsx

"use client"

import Image from "next/image"
import Link from "next/link"
import { Product } from "@medusajs/medusa-js"

interface ProductCardProps {
  product: Product
}

export default function ProductCard({ product }: ProductCardProps) {
  // 获取产品主图
  const thumbnail = product.images?.[0]?.url || "/placeholder.png"

  // 获取最低价格
  const getLowestPrice = () => {
    if (!product.variants || product.variants.length === 0) {
      return null
    }

    const prices = product.variants
      .map((v) => v.prices?.[0]?.amount)
      .filter((p): p is number => p !== undefined)

    if (prices.length === 0) return null

    const minPrice = Math.min(...prices)
    return (minPrice / 100).toFixed(2)
  }

  // 获取原价(如果有折扣)
  const getOriginalPrice = () => {
    const variants = product.variants || []
    const price = variants[0]?.prices?.[0]
    if (price && price.original_amount) {
      return (price.original_amount / 100).toFixed(2)
    }
    return null
  }

  const lowestPrice = getLowestPrice()
  const originalPrice = getOriginalPrice()
  const hasDiscount = originalPrice && lowestPrice && parseFloat(originalPrice) > parseFloat(lowestPrice)

  return (
    <Link href={`/products/${product.handle}`} className="group">
      <div className="relative aspect-square overflow-hidden rounded-lg bg-gray-100">
        <Image
          src={thumbnail}
          alt={product.title}
          fill
          className="object-cover transition-transform duration-300 group-hover:scale-105"
        />

        {/* 折扣标签 */}
        {hasDiscount && (
          <div className="absolute left-2 top-2 rounded-full bg-red-500 px-2 py-1 text-xs text-white">
            特惠
          </div>
        )}

        {/* 缺货标签 */}
        {product.variants?.[0]?.inventory_quantity === 0 && (
          <div className="absolute inset-0 flex items-center justify-center bg-black/50">
            <span className="text-white font-medium">暂时缺货</span>
          </div>
        )}
      </div>

      {/* 产品信息 */}
      <div className="mt-4 space-y-1">
        {/* 分类 */}
        {product.collection && (
          <p className="text-xs text-gray-500 uppercase tracking-wide">
            {product.collection.title}
          </p>
        )}

        {/* 标题 */}
        <h3 className="font-medium text-gray-900 group-hover:text-blue-600 transition-colors">
          {product.title}
        </h3>

        {/* 副标题 */}
        {product.subtitle && (
          <p className="text-sm text-gray-600 line-clamp-1">
            {product.subtitle}
          </p>
        )}

        {/* 价格 */}
        <div className="flex items-center gap-2 mt-2">
          {lowestPrice && (
            <span className="text-lg font-bold text-gray-900">
              ¥{lowestPrice}
            </span>
          )}
          {hasDiscount && originalPrice && (
            <span className="text-sm text-gray-400 line-through">
              ¥{originalPrice}
            </span>
          )}
        </div>

        {/* 颜色选项预览 */}
        {product.options?.find((opt) => opt.title === "颜色") && (
          <div className="flex gap-1 mt-2">
            {product.options
              .find((opt) => opt.title === "颜色")
              ?.values.slice(0, 4)
              .map((color, index) => (
                <span
                  key={index}
                  className="w-4 h-4 rounded-full border border-gray-200"
                  style={{ backgroundColor: getColorCode(color) }}
                  title={color}
                />
              ))}
            {(product.options.find((opt) => opt.title === "颜色")?.values.length || 0) > 4 && (
              <span className="text-xs text-gray-400">+更多</span>
            )}
          </div>
        )}
      </div>
    </Link>
  )
}

// 颜色名称到 HEX 代码的映射
function getColorCode(colorName: string): string {
  const colorMap: Record<string, string> = {
    红色: "#DC2626",
    蓝色: "#2563EB",
    黑色: "#111827",
    白色: "#FFFFFF",
    灰色: "#6B7280",
    绿色: "#16A34A",
    黄色: "#EAB308",
    紫色: "#9333EA",
    粉色: "#EC4899",
    橙色: "#F97316",
  }
  return colorMap[colorName] || "#E5E7EB"
}

步骤 5:创建产品列表页面

创建 src/app/page.tsx

"use client"

import { useState, useEffect } from "react"
import ProductCard from "@/components/ProductCard"
import { Product } from "@medusajs/medusa-js"

export default function HomePage() {
  const [products, setProducts] = useState<Product[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
  const [page, setPage] = useState(0)
  const [hasMore, setHasMore] = useState(true)

  const LIMIT = 12

  // 获取产品列表
  const fetchProducts = async (offset: number = 0, append: boolean = false) => {
    try {
      setLoading(true)
      const response = await fetch(`/api/products?limit=${LIMIT}&offset=${offset}`)
      const data = await response.json()

      if (append) {
        setProducts((prev) => [...prev, ...data.products])
      } else {
        setProducts(data.products)
      }
      setHasMore(data.hasMore)
      setError(null)
    } catch (err) {
      setError("加载产品失败,请稍后重试")
      console.error(err)
    } finally {
      setLoading(false)
    }
  }

  // 初始加载
  useEffect(() => {
    fetchProducts()
  }, [])

  // 加载更多
  const loadMore = () => {
    const newOffset = page * LIMIT + LIMIT
    setPage(page + 1)
    fetchProducts(newOffset, true)
  }

  return (
    <main className="min-h-screen bg-white">
      {/* Hero 区域 */}
      <section className="relative bg-gray-900 text-white">
        <div className="container mx-auto px-4 py-24">
          <h1 className="text-4xl md:text-6xl font-bold mb-6">
            发现品质生活
          </h1>
          <p className="text-xl text-gray-300 mb-8 max-w-2xl">
            精选优质商品为您带来舒适的购物体验每一件商品都经过精心挑选确保品质与性价比
          </p>
          <button className="bg-white text-gray-900 px-8 py-3 rounded-lg font-medium hover:bg-gray-100 transition-colors">
            立即选购
          </button>
        </div>
      </section>

      {/* 产品网格 */}
      <section className="container mx-auto px-4 py-16">
        <div className="flex items-center justify-between mb-8">
          <h2 className="text-2xl font-bold text-gray-900">热门商品</h2>
          <div className="flex gap-2">
            <button className="px-4 py-2 rounded-full bg-gray-100 text-sm font-medium">
              全部
            </button>
            <button className="px-4 py-2 rounded-full hover:bg-gray-100 text-sm font-medium text-gray-600">
              新品
            </button>
            <button className="px-4 py-2 rounded-full hover:bg-gray-100 text-sm font-medium text-gray-600">
              热销
            </button>
          </div>
        </div>

        {/* 加载状态 */}
        {loading && products.length === 0 && (
          <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
            {[...Array(8)].map((_, i) => (
              <div key={i} className="animate-pulse">
                <div className="aspect-square bg-gray-200 rounded-lg" />
                <div className="mt-4 h-4 bg-gray-200 rounded w-3/4" />
                <div className="mt-2 h-4 bg-gray-200 rounded w-1/2" />
              </div>
            ))}
          </div>
        )}

        {/* 错误状态 */}
        {error && (
          <div className="text-center py-12">
            <p className="text-red-500">{error}</p>
            <button
              onClick={() => fetchProducts()}
              className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg"
            >
              重试
            </button>
          </div>
        )}

        {/* 产品列表 */}
        {!error && (
          <>
            <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
              {products.map((product) => (
                <ProductCard key={product.id} product={product} />
              ))}
            </div>

            {/* 加载更多按钮 */}
            {hasMore && !loading && (
              <div className="text-center mt-12">
                <button
                  onClick={loadMore}
                  className="px-8 py-3 border-2 border-gray-900 text-gray-900 rounded-lg font-medium hover:bg-gray-900 hover:text-white transition-colors"
                >
                  加载更多
                </button>
              </div>
            )}

            {/* 加载中状态 */}
            {loading && products.length > 0 && (
              <div className="text-center mt-8">
                <div className="inline-block w-8 h-8 border-4 border-gray-200 border-t-gray-900 rounded-full animate-spin" />
              </div>
            )}
          </>
        )}
      </section>
    </main>
  )
}

步骤 6:创建产品详情页面

创建 src/app/products/[handle]/page.tsx

"use client"

import { useState, useEffect } from "react"
import { useParams } from "next/navigation"
import Image from "next/image"
import { Product, ProductVariant } from "@medusajs/medusa-js"

export default function ProductDetailPage() {
  const params = useParams()
  const handle = params.handle as string

  const [product, setProduct] = useState<Product | null>(null)
  const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null)
  const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>({})
  const [quantity, setQuantity] = useState(1)
  const [loading, setLoading] = useState(true)
  const [selectedImage, setSelectedImage] = useState(0)

  // 获取产品详情
  useEffect(() => {
    const fetchProduct = async () => {
      try {
        const response = await fetch(`/api/products/${handle}`)
        const data = await response.json()
        setProduct(data.product)

        // 初始化选中的选项
        if (data.product?.options) {
          const initialOptions: Record<string, string> = {}
          data.product.options.forEach((opt: any) => {
            if (opt.values && opt.values.length > 0) {
              initialOptions[opt.id] = opt.values[0]
            }
          })
          setSelectedOptions(initialOptions)
        }
      } catch (error) {
        console.error("获取产品失败:", error)
      } finally {
        setLoading(false)
      }
    }

    if (handle) {
      fetchProduct()
    }
  }, [handle])

  // 根据选择的选项查找匹配的变体
  useEffect(() => {
    if (!product?.variants) return

    const matchingVariant = product.variants.find((variant) => {
      return variant.options?.every(
        (opt) => selectedOptions[opt.option_id] === opt.value
      )
    })

    setSelectedVariant(matchingVariant || null)
  }, [selectedOptions, product])

  // 获取当前价格
  const getCurrentPrice = () => {
    if (!selectedVariant) return null
    const price = selectedVariant.prices?.[0]
    if (!price) return null
    return (price.amount / 100).toFixed(2)
  }

  // 获取原价
  const getOriginalPrice = () => {
    if (!selectedVariant) return null
    const price = selectedVariant.prices?.[0]
    if (!price?.original_amount) return null
    return (price.original_amount / 100).toFixed(2)
  }

  // 添加到购物车
  const addToCart = async () => {
    if (!selectedVariant) {
      alert("请选择完整的商品规格")
      return
    }

    try {
      const response = await fetch("/api/cart", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          variant_id: selectedVariant.id,
          quantity: quantity,
        }),
      })

      if (response.ok) {
        alert("已添加到购物车!")
        // 可以在这里更新购物车状态或显示侧边栏
      }
    } catch (error) {
      console.error("添加失败:", error)
      alert("添加失败,请稍后重试")
    }
  }

  if (loading) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="w-12 h-12 border-4 border-gray-200 border-t-blue-600 rounded-full animate-spin" />
      </div>
    )
  }

  if (!product) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="text-center">
          <h1 className="text-2xl font-bold text-gray-900 mb-4">产品未找到</h1>
          <a href="/" className="text-blue-600 hover:underline">
            返回首页
          </a>
        </div>
      </div>
    )
  }

  const images = product.images || []
  const currentPrice = getCurrentPrice()
  const originalPrice = getOriginalPrice()
  const hasDiscount = originalPrice && currentPrice && parseFloat(originalPrice) > parseFloat(currentPrice)

  return (
    <main className="min-h-screen bg-white">
      <div className="container mx-auto px-4 py-8">
        {/* 面包屑导航 */}
        <nav className="mb-6 text-sm">
          <ol className="flex items-center gap-2 text-gray-500">
            <li>
              <a href="/" className="hover:text-blue-600">首页</a>
            </li>
            <li>/</li>
            {product.collection && (
              <>
                <li>
                  <a href={`/collections/${product.collection.handle}`} className="hover:text-blue-600">
                    {product.collection.title}
                  </a>
                </li>
                <li>/</li>
              </>
            )}
            <li className="text-gray-900">{product.title}</li>
          </ol>
        </nav>

        <div className="grid md:grid-cols-2 gap-12">
          {/* 左侧:图片展示 */}
          <div className="space-y-4">
            {/* 主图 */}
            <div className="relative aspect-square rounded-lg overflow-hidden bg-gray-100">
              {images[selectedImage] ? (
                <Image
                  src={images[selectedImage].url}
                  alt={product.title}
                  fill
                  className="object-cover"
                  priority
                />
              ) : (
                <div className="flex items-center justify-center h-full text-gray-400">
                  暂无图片
                </div>
              )}

              {/* 折扣标签 */}
              {hasDiscount && (
                <div className="absolute top-4 left-4 bg-red-500 text-white px-3 py-1 rounded-full text-sm font-medium">
                  特惠
                </div>
              )}
            </div>

            {/* 缩略图列表 */}
            {images.length > 1 && (
              <div className="flex gap-3 overflow-x-auto pb-2">
                {images.map((img, index) => (
                  <button
                    key={img.id}
                    onClick={() => setSelectedImage(index)}
                    className={`relative flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 transition-colors ${
                      selectedImage === index ? "border-blue-600" : "border-gray-200 hover:border-gray-300"
                    }`}
                  >
                    <Image
                      src={img.url}
                      alt={`${product.title} - 图 ${index + 1}`}
                      fill
                      className="object-cover"
                    />
                  </button>
                ))}
              </div>
            )}
          </div>

          {/* 右侧:产品信息 */}
          <div className="space-y-6">
            {/* 标题和评分 */}
            <div>
              <h1 className="text-3xl font-bold text-gray-900 mb-2">
                {product.title}
              </h1>
              {product.subtitle && (
                <p className="text-lg text-gray-600">{product.subtitle}</p>
              )}
              {/* 评分组件 */}
              <div className="flex items-center gap-2 mt-3">
                <div className="flex text-yellow-400">
                  {[...Array(5)].map((_, i) => (
                    <svg key={i} className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
                      <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
                    </svg>
                  ))}
                </div>
                <span className="text-sm text-gray-500">4.8 (128条评价)</span>
              </div>
            </div>

            {/* 价格 */}
            <div className="border-y border-gray-200 py-6">
              <div className="flex items-baseline gap-3">
                {currentPrice && (
                  <>
                    <span className="text-3xl font-bold text-gray-900">
                      ¥{currentPrice}
                    </span>
                    {hasDiscount && originalPrice && (
                      <span className="text-xl text-gray-400 line-through">
                        ¥{originalPrice}
                      </span>
                    )}
                  </>
                )}
                {!currentPrice && (
                  <span className="text-xl text-gray-400">价格待定</span>
                )}
              </div>
              {hasDiscount && (
                <p className="text-sm text-red-500 mt-2">
                  限时优惠直降 {((1 - parseFloat(currentPrice!) / parseFloat(originalPrice!)) * 100).toFixed(0)}%
                </p>
              )}
            </div>

            {/* 产品描述 */}
            {product.description && (
              <div>
                <h3 className="font-medium text-gray-900 mb-2">商品详情</h3>
                <div className="prose prose-gray text-gray-600">
                  <p>{product.description}</p>
                </div>
              </div>
            )}

            {/* 变体选择 */}
            {product.options && product.options.length > 0 && (
              <div className="space-y-4">
                {product.options.map((option) => (
                  <div key={option.id}>
                    <label className="block text-sm font-medium text-gray-700 mb-2">
                      {option.title}
                      {selectedOptions[option.id] && (
                        <span className="font-normal text-gray-500 ml-2">
                          - {selectedOptions[option.id]}
                        </span>
                      )}
                    </label>
                    <div className="flex flex-wrap gap-2">
                      {option.values.map((value) => {
                        const isSelected = selectedOptions[option.id] === value
                        const isAvailable = checkOptionAvailability(product, option.id, value)

                        return (
                          <button
                            key={value}
                            onClick={() => {
                              setSelectedOptions({
                                ...selectedOptions,
                                [option.id]: value,
                              })
                            }}
                            disabled={!isAvailable}
                            className={`px-4 py-2 rounded-lg border text-sm font-medium transition-colors ${
                              isSelected
                                ? "border-blue-600 bg-blue-50 text-blue-600"
                                : isAvailable
                                ? "border-gray-300 hover:border-gray-400"
                                : "border-gray-200 text-gray-300 cursor-not-allowed"
                            }`}
                          >
                            {value}
                          </button>
                        )
                      })}
                    </div>
                  </div>
                ))}
              </div>
            )}

            {/* 数量选择 */}
            <div>
              <label className="block text-sm font-medium text-gray-700 mb-2">
                数量
              </label>
              <div className="flex items-center">
                <button
                  onClick={() => setQuantity(Math.max(1, quantity - 1))}
                  className="w-10 h-10 border border-gray-300 rounded-l-lg flex items-center justify-center hover:bg-gray-100"
                >
                  -
                </button>
                <input
                  type="number"
                  value={quantity}
                  onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
                  className="w-16 h-10 border-y border-gray-300 text-center focus:outline-none focus:ring-2 focus:ring-blue-500"
                />
                <button
                  onClick={() => setQuantity(quantity + 1)}
                  className="w-10 h-10 border border-gray-300 rounded-r-lg flex items-center justify-center hover:bg-gray-100"
                >
                  +
                </button>
              </div>
            </div>

            {/* 操作按钮 */}
            <div className="flex gap-4">
              <button
                onClick={addToCart}
                disabled={!selectedVariant || selectedVariant.inventory_quantity === 0}
                className={`flex-1 py-4 rounded-lg font-medium text-lg transition-colors ${
                  selectedVariant && selectedVariant.inventory_quantity > 0
                    ? "bg-blue-600 text-white hover:bg-blue-700"
                    : "bg-gray-200 text-gray-400 cursor-not-allowed"
                }`}
              >
                {selectedVariant && selectedVariant.inventory_quantity > 0
                  ? "加入购物车"
                  : selectedVariant
                  ? "暂时缺货"
                  : "请选择规格"}
              </button>
              <button className="w-14 h-14 border border-gray-300 rounded-lg flex items-center justify-center hover:bg-gray-50">
                <svg className="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
                </svg>
              </button>
            </div>

            {/* 服务保障 */}
            <div className="grid grid-cols-2 gap-4 pt-6 border-t border-gray-200">
              <div className="flex items-center gap-2 text-sm text-gray-600">
                <svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
                </svg>
                7天无理由退换
              </div>
              <div className="flex items-center gap-2 text-sm text-gray-600">
                <svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
                </svg>
                正品保障
              </div>
              <div className="flex items-center gap-2 text-sm text-gray-600">
                <svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
                </svg>
                48小时发货
              </div>
              <div className="flex items-center gap-2 text-sm text-gray-600">
                <svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
                </svg>
                售后无忧
              </div>
            </div>
          </div>
        </div>
      </div>
    </main>
  )
}

// 检查某个选项值是否可用
function checkOptionAvailability(
  product: Product,
  optionId: string,
  value: string
): boolean {
  if (!product.variants) return true

  // 检查至少有一个变体包含这个选项组合且有库存
  return product.variants.some((variant) => {
    const hasOption = variant.options?.some(
      (opt) => opt.option_id === optionId && opt.value === value
    )
    const hasStock = variant.inventory_quantity && variant.inventory_quantity > 0
    return hasOption && hasStock
  })
}

创建对应的 API 路由 src/app/api/products/[handle]/route.ts

import { NextResponse } from "next/server"
import medusa from "@/lib/medusa"

export async function GET(
  request: Request,
  { params }: { params: { handle: string } }
) {
  const handle = params.handle

  try {
    const response = await medusa.products.retrieveByHandle(handle)

    return NextResponse.json({
      product: response.product,
    })
  } catch (error) {
    console.error("获取产品详情失败:", error)
    return NextResponse.json(
      { error: "产品未找到" },
      { status: 404 }
    )
  }
}

教程二:实现完整的购物车功能

购物车是电商系统的核心功能之一。本教程将详细讲解如何实现购物车的增删改查功能。

步骤 1:创建购物车 API 路由

创建 src/app/api/cart/route.ts

import { NextResponse } from "next/server"
import { cookies } from "next/headers"
import medusa from "@/lib/medusa"

// 获取或创建购物车
export async function GET() {
  try {
    const cookieStore = cookies()
    const cartId = cookieStore.get("cart_id")?.value

    if (!cartId) {
      // 创建新购物车
      const response = await medusa.carts.create({
        region_id: "reg_default",  // 使用默认区域
        country_code: "CN",       // 中国
      })

      const cart = response.cart

      // 设置 cookie
      const responseWithCookie = NextResponse.json({ cart })
      responseWithCookie.cookies.set("cart_id", cart.id, {
        httpOnly: true,
        secure: process.env.NODE_ENV === "production",
        sameSite: "lax",
        maxAge: 60 * 60 * 24 * 7,  // 7天
      })

      return responseWithCookie
    }

    // 获取现有购物车
    const response = await medusa.carts.retrieve(cartId)
    return NextResponse.json({ cart: response.cart })
  } catch (error) {
    console.error("获取购物车失败:", error)
    return NextResponse.json(
      { error: "获取购物车失败" },
      { status: 500 }
    )
  }
}

// 添加商品到购物车
export async function POST(request: Request) {
  try {
    const cookieStore = cookies()
    let cartId = cookieStore.get("cart_id")?.value

    const body = await request.json()
    const { variant_id, quantity } = body

    if (!variant_id || !quantity) {
      return NextResponse.json(
        { error: "缺少必要参数" },
        { status: 400 }
      )
    }

    let cart

    if (!cartId) {
      // 创建新购物车并添加商品
      const createResponse = await medusa.carts.create({
        region_id: "reg_default",
        country_code: "CN",
      })
      cart = createResponse.cart
      cartId = cart.id
    }

    // 添加商品到购物车
    const addResponse = await medusa.carts.lineItems.create(cartId, {
      variant_id,
      quantity,
    })
    cart = addResponse.cart

    // 更新 cookie
    const response = NextResponse.json({ cart })
    response.cookies.set("cart_id", cartId, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "lax",
      maxAge: 60 * 60 * 24 * 7,
    })

    return response
  } catch (error) {
    console.error("添加商品失败:", error)
    return NextResponse.json(
      { error: "添加商品失败" },
      { status: 500 }
    )
  }
}

// 更新购物车商品数量
export async function PUT(request: Request) {
  try {
    const cookieStore = cookies()
    const cartId = cookieStore.get("cart_id")?.value

    if (!cartId) {
      return NextResponse.json(
        { error: "购物车不存在" },
        { status: 404 }
      )
    }

    const body = await request.json()
    const { line_id, quantity } = body

    if (!line_id || quantity === undefined) {
      return NextResponse.json(
        { error: "缺少必要参数" },
        { status: 400 }
      )
    }

    // 如果数量为0,则删除商品
    if (quantity === 0) {
      const deleteResponse = await medusa.carts.lineItems.delete(cartId, line_id)
      return NextResponse.json({ cart: deleteResponse.cart })
    }

    // 更新商品数量
    const updateResponse = await medusa.carts.lineItems.update(cartId, line_id, {
      quantity,
    })

    return NextResponse.json({ cart: updateResponse.cart })
  } catch (error) {
    console.error("更新商品失败:", error)
    return NextResponse.json(
      { error: "更新商品失败" },
      { status: 500 }
    )
  }
}

// 删除购物车商品
export async function DELETE(request: Request) {
  try {
    const cookieStore = cookies()
    const cartId = cookieStore.get("cart_id")?.value

    if (!cartId) {
      return NextResponse.json(
        { error: "购物车不存在" },
        { status: 404 }
      )
    }

    const { searchParams } = new URL(request.url)
    const lineId = searchParams.get("line_id")

    if (!lineId) {
      return NextResponse.json(
        { error: "缺少商品ID" },
        { status: 400 }
      )
    }

    const deleteResponse = await medusa.carts.lineItems.delete(cartId, lineId)

    return NextResponse.json({ cart: deleteResponse.cart })
  } catch (error) {
    console.error("删除商品失败:", error)
    return NextResponse.json(
      { error: "删除商品失败" },
      { status: 500 }
    )
  }
}

步骤 2:创建购物车组件

创建 src/components/CartDrawer.tsx

"use client"

import { useState, useEffect } from "react"
import Image from "next/image"
import Link from "next/link"

interface CartItem {
  id: string
  title: string
  variant_title?: string
  thumbnail?: string
  quantity: number
  unit_price: number
  total: number
}

interface Cart {
  id: string
  items: CartItem[]
  subtotal: number
  total: number
  item_count: number
}

export default function CartDrawer({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
  const [cart, setCart] = useState<Cart | null>(null)
  const [loading, setLoading] = useState(false)

  // 获取购物车数据
  useEffect(() => {
    if (isOpen) {
      fetchCart()
    }
  }, [isOpen])

  const fetchCart = async () => {
    try {
      const response = await fetch("/api/cart")
      const data = await response.json()
      setCart(data.cart)
    } catch (error) {
      console.error("获取购物车失败:", error)
    }
  }

  // 更新商品数量
  const updateQuantity = async (lineId: string, quantity: number) => {
    setLoading(true)
    try {
      const response = await fetch("/api/cart", {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ line_id: lineId, quantity }),
      })
      const data = await response.json()
      setCart(data.cart)
    } catch (error) {
      console.error("更新失败:", error)
    } finally {
      setLoading(false)
    }
  }

  // 删除商品
  const removeItem = async (lineId: string) => {
    setLoading(true)
    try {
      const response = await fetch(`/api/cart?line_id=${lineId}`, {
        method: "DELETE",
      })
      const data = await response.json()
      setCart(data.cart)
    } catch (error) {
      console.error("删除失败:", error)
    } finally {
      setLoading(false)
    }
  }

  return (
    <>
      {/* 遮罩层 */}
      {isOpen && (
        <div
          className="fixed inset-0 bg-black/50 z-40"
          onClick={onClose}
        />
      )}

      {/* 购物车侧边栏 */}
      <div
        className={`fixed top-0 right-0 h-full w-full max-w-md bg-white shadow-2xl z-50 transform transition-transform duration-300 ${
          isOpen ? "translate-x-0" : "translate-x-full"
        }`}
      >
        {/* 头部 */}
        <div className="flex items-center justify-between p-6 border-b">
          <h2 className="text-xl font-bold text-gray-900">
            购物车 ({cart?.item_count || 0})
          </h2>
          <button
            onClick={onClose}
            className="p-2 hover:bg-gray-100 rounded-full transition-colors"
          >
            <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
            </svg>
          </button>
        </div>

        {/* 购物车内容 */}
        <div className="flex flex-col h-[calc(100%-180px)]">
          {!cart || cart.items.length === 0 ? (
            <div className="flex-1 flex flex-col items-center justify-center p-6">
              <svg className="w-24 h-24 text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
              </svg>
              <p className="text-gray-500 text-lg mb-4">购物车是空的</p>
              <Link
                href="/"
                onClick={onClose}
                className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
              >
                去逛逛
              </Link>
            </div>
          ) : (
            <div className="flex-1 overflow-y-auto p-4 space-y-4">
              {cart.items.map((item) => (
                <div key={item.id} className="flex gap-4 p-4 bg-gray-50 rounded-lg">
                  {/* 商品图片 */}
                  <div className="relative w-24 h-24 bg-white rounded-lg overflow-hidden flex-shrink-0">
                    {item.thumbnail ? (
                      <Image
                        src={item.thumbnail}
                        alt={item.title}
                        fill
                        className="object-cover"
                      />
                    ) : (
                      <div className="w-full h-full flex items-center justify-center text-gray-400">
                        无图
                      </div>
                    )}
                  </div>

                  {/* 商品信息 */}
                  <div className="flex-1 min-w-0">
                    <h3 className="font-medium text-gray-900 truncate">
                      {item.title}
                    </h3>
                    {item.variant_title && (
                      <p className="text-sm text-gray-500 mt-1">
                        {item.variant_title}
                      </p>
                    )}

                    <div className="flex items-center justify-between mt-2">
                      <span className="font-bold text-gray-900">
                        ¥{(item.unit_price / 100).toFixed(2)}
                      </span>

                      {/* 数量控制 */}
                      <div className="flex items-center gap-2">
                        <button
                          onClick={() => updateQuantity(item.id, item.quantity - 1)}
                          disabled={loading}
                          className="w-8 h-8 flex items-center justify-center border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50"
                        >
                          -
                        </button>
                        <span className="w-8 text-center">{item.quantity}</span>
                        <button
                          onClick={() => updateQuantity(item.id, item.quantity + 1)}
                          disabled={loading}
                          className="w-8 h-8 flex items-center justify-center border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50"
                        >
                          +
                        </button>
                      </div>
                    </div>
                  </div>

                  {/* 删除按钮 */}
                  <button
                    onClick={() => removeItem(item.id)}
                    disabled={loading}
                    className="p-2 text-gray-400 hover:text-red-500 transition-colors self-start"
                  >
                    <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
                    </svg>
                  </button>
                </div>
              ))}
            </div>
          )}
        </div>

        {/* 底部结算 */}
        {cart && cart.items.length > 0 && (
          <div className="absolute bottom-0 left-0 right-0 p-6 bg-white border-t">
            <div className="flex items-center justify-between mb-4">
              <span className="text-gray-600">小计</span>
              <span className="text-2xl font-bold text-gray-900">
                ¥{(cart.subtotal / 100).toFixed(2)}
              </span>
            </div>
            <p className="text-sm text-gray-500 mb-4">
              运费和优惠将在结算时计算
            </p>
            <Link
              href="/checkout"
              onClick={onClose}
              className="block w-full py-4 bg-blue-600 text-white text-center font-bold rounded-lg hover:bg-blue-700 transition-colors"
            >
              去结算
            </Link>
          </div>
        )}
      </div>
    </>
  )
}

教程三:实现用户认证系统

用户认证是电商平台的基础功能。本教程将详细讲解如何实现完整的用户认证流程。

步骤 1:创建认证相关的 API 路由

创建 src/app/api/auth/[...auth]/route.ts

import { NextResponse } from "next/server"
import { cookies } from "next/headers"
import medusa from "@/lib/medusa"

// 处理认证相关的请求
export async function POST(request: Request) {
  try {
    const { pathname } = new URL(request.url)
    const segments = pathname.split("/")
    const action = segments[segments.length - 1]

    const body = await request.json()

    switch (action) {
      case "register": {
        // 用户注册
        const { email, password, first_name, last_name, phone } = body

        // 创建客户账户
        const response = await medusa.customers.create({
          email,
          password,
          first_name,
          last_name,
          phone,
        })

        const customer = response.customer

        // 创建购物车并关联客户
        const cartResponse = await medusa.carts.create({
          customer_id: customer.id,
          region_id: "reg_default",
          country_code: "CN",
        })

        // 设置认证 cookie
        const responseWithCookie = NextResponse.json({
          customer,
          cart: cartResponse.cart,
        })

        responseWithCookie.cookies.set("customer_id", customer.id, {
          httpOnly: true,
          secure: process.env.NODE_ENV === "production",
          sameSite: "lax",
          maxAge: 60 * 60 * 24 * 7,
        })

        return responseWithCookie
      }

      case "login": {
        // 用户登录
        const { email, password } = body

        try {
          const response = await medusa.auth.authenticate({
            email,
            password,
          })

          const { customer } = response

          if (!customer) {
            return NextResponse.json(
              { error: "登录失败,请检查邮箱和密码" },
              { status: 401 }
            )
          }

          // 获取或创建购物车
          let cartResponse
          try {
            cartResponse = await medusa.carts.retrieve(cartId)
          } catch {
            cartResponse = await medusa.carts.create({
              customer_id: customer.id,
              region_id: "reg_default",
              country_code: "CN",
            })
          }

          const responseWithCookie = NextResponse.json({
            customer,
            cart: cartResponse.cart,
          })

          responseWithCookie.cookies.set("customer_id", customer.id, {
            httpOnly: true,
            secure: process.env.NODE_ENV === "production",
            sameSite: "lax",
            maxAge: 60 * 60 * 24 * 7,
          })

          return responseWithCookie
        } catch (error) {
          return NextResponse.json(
            { error: "邮箱或密码错误" },
            { status: 401 }
          )
        }
      }

      case "logout": {
        // 用户登出
        const response = NextResponse.json({ success: true })
        response.cookies.delete("customer_id")
        return response
      }

      case "session": {
        // 获取当前会话
        const cookieStore = cookies()
        const customerId = cookieStore.get("customer_id")?.value

        if (!customerId) {
          return NextResponse.json({ customer: null })
        }

        try {
          const response = await medusa.customers.retrieve(customerId)
          return NextResponse.json({ customer: response.customer })
        } catch {
          const response = NextResponse.json({ customer: null })
          response.cookies.delete("customer_id")
          return response
        }
      }

      default:
        return NextResponse.json(
          { error: "未知的认证操作" },
          { status: 400 }
        )
    }
  } catch (error) {
    console.error("认证错误:", error)
    return NextResponse.json(
      { error: "服务器错误" },
      { status: 500 }
    )
  }
}

步骤 2:创建认证 Hook

创建 src/hooks/useAuth.ts

"use client"

import { useState, useEffect, createContext, useContext } from "react"

interface Customer {
  id: string
  email: string
  first_name: string
  last_name: string
  phone?: string
  metadata?: Record<string, any>
}

interface AuthContextType {
  customer: Customer | null
  loading: boolean
  login: (email: string, password: string) => Promise<void>
  register: (data: RegisterData) => Promise<void>
  logout: () => Promise<void>
  refresh: () => Promise<void>
}

interface RegisterData {
  email: string
  password: string
  first_name: string
  last_name: string
  phone?: string
}

const AuthContext = createContext<AuthContextType | null>(null)

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [customer, setCustomer] = useState<Customer | null>(null)
  const [loading, setLoading] = useState(true)

  // 初始化时检查会话
  useEffect(() => {
    checkSession()
  }, [])

  const checkSession = async () => {
    try {
      const response = await fetch("/api/auth/session", {
        method: "POST",
      })
      const data = await response.json()
      setCustomer(data.customer)
    } catch (error) {
      console.error("检查会话失败:", error)
    } finally {
      setLoading(false)
    }
  }

  const login = async (email: string, password: string) => {
    setLoading(true)
    try {
      const response = await fetch("/api/auth/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email, password }),
      })

      const data = await response.json()

      if (!response.ok) {
        throw new Error(data.error || "登录失败")
      }

      setCustomer(data.customer)
    } finally {
      setLoading(false)
    }
  }

  const register = async (data: RegisterData) => {
    setLoading(true)
    try {
      const response = await fetch("/api/auth/register", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      })

      const result = await response.json()

      if (!response.ok) {
        throw new Error(result.error || "注册失败")
      }

      setCustomer(result.customer)
    } finally {
      setLoading(false)
    }
  }

  const logout = async () => {
    setLoading(true)
    try {
      await fetch("/api/auth/logout", {
        method: "POST",
      })
      setCustomer(null)
    } finally {
      setLoading(false)
    }
  }

  const refresh = async () => {
    await checkSession()
  }

  return (
    <AuthContext.Provider
      value={{
        customer,
        loading,
        login,
        register,
        logout,
        refresh,
      }}
    >
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error("useAuth must be used within an AuthProvider")
  }
  return context
}

步骤 3:创建登录页面组件

创建 src/components/LoginForm.tsx

"use client"

import { useState } from "react"
import { useAuth } from "@/hooks/useAuth"
import Link from "next/link"

export default function LoginForm() {
  const [email, setEmail] = useState("")
  const [password, setPassword] = useState("")
  const [error, setError] = useState("")
  const [loading, setLoading] = useState(false)

  const { login } = useAuth()

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setError("")
    setLoading(true)

    try {
      await login(email, password)
      // 登录成功后可以跳转到指定页面
      window.location.href = "/"
    } catch (err) {
      setError(err instanceof Error ? err.message : "登录失败")
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-md w-full space-y-8">
        {/* Logo 和标题 */}
        <div className="text-center">
          <h2 className="text-3xl font-bold text-gray-900">欢迎回来</h2>
          <p className="mt-2 text-gray-600">
            登录您的账户继续购物
          </p>
        </div>

        {/* 登录表单 */}
        <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
          {error && (
            <div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg">
              {error}
            </div>
          )}

          <div className="space-y-4">
            <div>
              <label htmlFor="email" className="block text-sm font-medium text-gray-700">
                邮箱地址
              </label>
              <input
                id="email"
                type="email"
                required
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                className="mt-1 block w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
                placeholder="your@email.com"
              />
            </div>

            <div>
              <label htmlFor="password" className="block text-sm font-medium text-gray-700">
                密码
              </label>
              <input
                id="password"
                type="password"
                required
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                className="mt-1 block w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
                placeholder="••••••••"
              />
            </div>
          </div>

          {/* 记住我和忘记密码 */}
          <div className="flex items-center justify-between">
            <label className="flex items-center">
              <input
                type="checkbox"
                className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
              />
              <span className="ml-2 text-sm text-gray-600">记住我</span>
            </label>
            <Link href="/forgot-password" className="text-sm text-blue-600 hover:underline">
              忘记密码
            </Link>
          </div>

          {/* 提交按钮 */}
          <button
            type="submit"
            disabled={loading}
            className="w-full py-3 bg-blue-600 text-white font-bold rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
          >
            {loading ? "登录中..." : "登录"}
          </button>

          {/* 社交登录 */}
          <div className="relative">
            <div className="absolute inset-0 flex items-center">
              <div className="w-full border-t border-gray-300" />
            </div>
            <div className="relative flex justify-center text-sm">
              <span className="px-2 bg-gray-50 text-gray-500"></span>
            </div>
          </div>

          <div className="grid grid-cols-2 gap-3">
            <button
              type="button"
              className="flex items-center justify-center gap-2 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
            >
              <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
                <path d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032s2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.503,2.988,15.139,2,12.545,2C7.021,2,2.543,6.477,2.543,12s4.478,10,10.002,10c8.396,0,10.249-7.85,9.426-11.748L12.545,10.239z"/>
              </svg>
              Google
            </button>
            <button
              type="button"
              className="flex items-center justify-center gap-2 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
            >
              <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
                <path d="M12 2C6.477 2 2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.879V14.89h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.989C18.343 21.129 22 16.99 22 12c0-5.523-4.477-10-10-10z"/>
              </svg>
              Facebook
            </button>
          </div>

          {/* 注册链接 */}
          <p className="text-center text-sm text-gray-600">
            还没有账户{" "}
            <Link href="/register" className="text-blue-600 font-medium hover:underline">
              立即注册
            </Link>
          </p>
        </form>
      </div>
    </div>
  )
}

教程四:创建自定义插件

Medusa 的插件系统非常强大,让我们创建一个自定义插件作为示例。

场景:实现一个商品浏览量统计插件

创建 src/plugins/product-views/index.ts

import { Medusa, Middleware, Request, Response } from "@medusajs/medusa"
import { Entity, Column, PrimaryColumn } from "typeorm"

// 定义实体类
@Entity()
class ProductView extends Entity {
  @PrimaryColumn()
  product_id!: string

  @Column({ type: "int", default: 0 })
  view_count!: number

  @Column({ type: "datetime", default: () => "CURRENT_TIMESTAMP" })
  last_viewed_at!: Date
}

// 定义服务类
class ProductViewsService {
  private viewCounts: Map<string, number> = new Map()
  private redis: any

  constructor() {
    // 初始化 Redis 连接(如果可用)
    this.initRedis()
  }

  private async initRedis() {
    try {
      const Redis = require("ioredis")
      this.redis = new Redis(process.env.REDIS_URL)
    } catch (error) {
      console.log("Redis 不可用,使用内存存储")
    }
  }

  // 记录商品浏览
  async recordView(productId: string): Promise<number> {
    // 更新内存中的计数
    const currentCount = this.viewCounts.get(productId) || 0
    const newCount = currentCount + 1
    this.viewCounts.set(productId, newCount)

    // 如果 Redis 可用,更新 Redis
    if (this.redis) {
      await this.redis.incr(`product_views:${productId}`)
    }

    return newCount
  }

  // 获取商品浏览量
  async getViewCount(productId: string): Promise<number> {
    if (this.redis) {
      const count = await this.redis.get(`product_views:${productId}`)
      return parseInt(count) || 0
    }

    return this.viewCounts.get(productId) || 0
  }

  // 获取热门商品
  async getTopViewedProducts(limit: number = 10): Promise<Array<{ product_id: string; view_count: number }>> {
    const products: Array<{ product_id: string; view_count: number }> = []

    if (this.redis) {
      // 从 Redis 获取热门商品
      const keys = await this.redis.keys("product_views:*")
      const views = await Promise.all(
        keys.map(async (key: string) => {
          const productId = key.replace("product_views:", "")
          const count = await this.redis.get(key)
          return { product_id: productId, view_count: parseInt(count) || 0 }
        })
      )

      return views
        .sort((a, b) => b.view_count - a.view_count)
        .slice(0, limit)
    }

    // 从内存获取热门商品
    for (const [productId, count] of this.viewCounts) {
      products.push({ product_id: productId, view_count: count })
    }

    return products
      .sort((a, b) => b.view_count - a.view_count)
      .slice(0, limit)
  }
}

// 定义中间件
class ProductViewMiddleware implements Middleware {
  private viewsService: ProductViewsService

  constructor() {
    this.viewsService = new ProductViewsService()
  }

  async use(req: Request, res: Response): Promise<void> {
    // 监听商品详情请求
    if (req.path.startsWith("/store/products/") && req.method === "GET") {
      const productId = this.extractProductId(req.path)

      if (productId) {
        // 异步记录浏览,不阻塞响应
        this.viewsService.recordView(productId).catch(console.error)
      }
    }
  }

  private extractProductId(path: string): string | null {
    // 从路径中提取商品 ID,例如: /store/products/prod_123
    const match = path.match(/\/store\/products\/([^\/]+)/)
    return match ? match[1] : null
  }
}

// 定义 API 路由
export const productViewsRoutes = (router) => {
  const viewsService = new ProductViewsService()

  // 获取指定商品的浏览量
  router.get("/admin/product-views/:productId", async (req, res) => {
    const { productId } = req.params
    const viewCount = await viewsService.getViewCount(productId)
    res.json({ product_id: productId, view_count: viewCount })
  })

  // 获取热门商品
  router.get("/admin/product-views/top", async (req, res) => {
    const limit = parseInt(req.query.limit) || 10
    const topProducts = await viewsService.getTopViewedProducts(limit)
    res.json({ products: topProducts })
  })
}

// 导出插件配置
export default {
  name: "product-views",
  version: "1.0.0",

  services: {
    ProductViewsService,
  },

  middleware: [
    {
      resolve: ProductViewMiddleware,
      options: {},
    },
  ],

  routes: [
    {
      route: "/product-views/*",
      handlers: productViewsRoutes,
    },
  ],

  // 插件加载时的初始化
  async onApplicationStart() {
    console.log("商品浏览量统计插件已加载")
  },

  // 插件卸载时的清理
  async onApplicationShutdown() {
    console.log("商品浏览量统计插件已卸载")
  },
}

medusa-config.js 中注册插件:

module.exports = {
  projectConfig: {
    databaseUrl: process.env.DATABASE_URL,
    redisUrl: process.env.REDIS_URL,
    jwtSecret: process.env.JWT_SECRET,
    cookieSecret: process.env.COOKIE_SECRET,
  },

  plugins: [
    // ... 其他插件

    {
      resolve: "./src/plugins/product-views",
      options: {
        // 插件配置选项
        enableMiddleware: true,
        cacheDuration: 3600,
      },
    },
  ],
}

常见使用场景和案例

场景一:多语言多货币电商平台

对于面向全球市场的电商平台,Medusa 的区域(Region)和多语言支持让这变得简单。

// 配置多个区域
const regions = [
  {
    id: "reg_cn",
    name: "中国",
    currency_code: "CNY",
    countries: ["CN"],
    language_code: "zh-CN",
    tax_rate: 0.13,  // 13% 增值税率
  },
  {
    id: "reg_us",
    name: "美国",
    currency_code: "USD",
    countries: ["US"],
    language_code: "en-US",
    tax_rate: 0.0,  // 美国各州税率不同,此处为示例
  },
  {
    id: "reg_eu",
    name: "欧元区",
    currency_code: "EUR",
    countries: ["DE", "FR", "IT", "ES"],
    language_code: "de-DE",
    tax_rate: 0.19,  // 德国增值税率
  },
]

// 根据用户区域返回对应的产品和价格
async function getProductsForRegion(regionId: string) {
  const products = await medusa.products.list({
    region_id: regionId,
    expand: ["variants.prices"],
  })

  // 转换价格到用户货币
  return products.map((product) => ({
    ...product,
    variants: product.variants.map((variant) => ({
      ...variant,
      // 价格已自动转换为对应货币
      display_price: formatPrice(variant.prices, regionId),
    })),
  }))
}

场景二:B2B 批发平台

B2B 场景通常需要客户特定定价、数量阶梯价等功能。

// 实现客户特定定价
class B2BPricingService {
  async calculatePrice(
    variantId: string,
    customerId: string,
    quantity: number
  ): Promise<PriceCalculation> {
    // 获取基础价格
    const variant = await this.variantService.retrieve(variantId)
    let basePrice = variant.prices[0].amount

    // 获取客户等级
    const customer = await this.customerService.retrieve(customerId)
    const customerLevel = customer.metadata?.level || "retail"

    // 应用客户等级折扣
    const levelDiscounts = {
      bronze: 0.95,   // 95折
      silver: 0.90,   // 9折
      gold: 0.85,     // 85折
      platinum: 0.80, // 8折
      wholesale: 0.70, // 7折
    }

    basePrice = basePrice * (levelDiscounts[customerLevel] || 1)

    // 应用数量阶梯价
    const tieredPricing = await this.getTieredPricing(variantId, quantity)
    if (tieredPricing) {
      basePrice = basePrice * tieredPricing.multiplier
    }

    return {
      unit_price: Math.round(basePrice),
      total_price: Math.round(basePrice * quantity),
      discount_reason: this.getDiscountReason(customerLevel, tieredPricing),
    }
  }

  async getTieredPricing(variantId: string, quantity: number) {
    const tiers = [
      { min_quantity: 100, multiplier: 0.95 },
      { min_quantity: 500, multiplier: 0.90 },
      { min_quantity: 1000, multiplier: 0.85 },
      { min_quantity: 5000, multiplier: 0.80 },
    ]

    const applicableTier = tiers
      .filter((tier) => quantity >= tier.min_quantity)
      .sort((a, b) => b.min_quantity - a.min_quantity)[0]

    return applicableTier || null
  }
}

场景三:订阅电商模式

实现定期配送的订阅服务。

// 订阅服务实现
class SubscriptionService {
  async createSubscription(data: CreateSubscriptionInput) {
    // 1. 创建订阅记录
    const subscription = await this.subscriptionRepository.create({
      customer_id: data.customer_id,
      variant_id: data.variant_id,
      quantity: data.quantity,
      interval: data.interval,  // "week" | "month" | "year"
      interval_count: data.interval_count || 1,
      next_delivery: this.calculateNextDelivery(data.interval),
      status: "active",
      payment_method_id: data.payment_method_id,
    })

    // 2. 初始化第一个订单
    await this.createSubscriptionOrder(subscription)

    // 3. 调度下次执行
    await this.scheduleNextOrder(subscription)

    return subscription
  }

  // 处理定期订单
  async processSubscriptionOrders() {
    const dueSubscriptions = await this.subscriptionRepository.find({
      where: {
        next_delivery: LessThanOrEqual(new Date()),
        status: "active",
      },
    })

    for (const subscription of dueSubscriptions) {
      try {
        // 创建新订单
        await this.createSubscriptionOrder(subscription)

        // 更新下次配送时间
        subscription.next_delivery = this.calculateNextDelivery(
          subscription.interval,
          subscription.interval_count
        )
        await this.subscriptionRepository.save(subscription)
      } catch (error) {
        // 处理失败,发送通知
        await this.notificationService.sendSubscriptionError(
          subscription,
          error
        )
      }
    }
  }
}

技巧和最佳实践

性能优化技巧

1. 数据库查询优化

// 避免 N+1 查询,使用展开参数
const products = await medusa.products.list({
  limit: 20,
  // 预先加载关联数据,减少数据库查询次数
  expand: [
    "variants",
    "variants.prices",
    "images",
    "collection",
    "tags",
  ],
})

// 使用 select 限制返回字段
const product = await medusa.products.retrieve(productId, {
  select: ["id", "title", "description", "thumbnail"],
})

2. 缓存策略

// 在服务中使用缓存
class ProductService {
  private cache: Map<string, { data: any; expiry: number }> = new Map()

  async getProductWithCache(productId: string) {
    const cached = this.cache.get(productId)

    if (cached && cached.expiry > Date.now()) {
      return cached.data
    }

    const product = await this.productRepository.findOne(productId)

    // 缓存 5 分钟
    this.cache.set(productId, {
      data: product,
      expiry: Date.now() + 5 * 60 * 1000,
    })

    return product
  }
}

3. 图片处理优化

// Next.js 图片配置
// next.config.js
module.exports = {
  images: {
    domains: ["cdn.example.com", "localhost"],
    formats: ["image/avif", "image/webp"],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],
  },
}

安全最佳实践

// 1. 验证用户权限
class OrderService {
  async retrieveOrder(orderId: string, customerId: string) {
    const order = await this.orderRepository.findOne({
      where: { id: orderId },
    })

    // 确保订单属于当前用户
    if (order.customer_id !== customerId) {
      throw new UnauthorizedError("无权访问此订单")
    }

    return order
  }
}

// 2. 防止 SQL 注入(使用参数化查询)
// Medusa 的 TypeORM 配置已自动处理
const products = await medusa.products.list({
  // 所有用户输入都会经过验证
  handle: validatedHandle,
})

// 3. 敏感数据处理
function sanitizeCustomerData(customer: Customer): Customer {
  const { password_hash, ...safeData } = customer
  return safeData
}

// 4. Rate Limiting
class RateLimitMiddleware {
  private requests: Map<string, number[]> = new Map()

  async use(req, res) {
    const clientId = req.ip
    const now = Date.now()
    const windowMs = 60000 // 1分钟窗口

    const requests = this.requests.get(clientId) || []
    const recentRequests = requests.filter((t) => t > now - windowMs)

    if (recentRequests.length >= 100) {
      return res.status(429).json({
        error: "请求过于频繁,请稍后再试",
      })
    }

    recentRequests.push(now)
    this.requests.set(clientId, recentRequests)
  }
}

部署建议

Docker 部署配置:

# Dockerfile
FROM node:18-alpine AS builder

WORKDIR /app

# 复制依赖文件
COPY package*.json ./
RUN npm ci --only=production

# 复制源代码
COPY . .

# 构建
RUN npm run build

# 生产镜像
FROM node:18-alpine

WORKDIR /app

COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./

# 创建非 root 用户
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nextjs -u 1001

USER nextjs

EXPOSE 9000

CMD ["node", "dist/index.js"]

docker-compose.yml:

version: "3.8"

services:
  medusa:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "9000:9000"
    environment:
      DATABASE_URL: postgres://postgres:postgres@db:5432/medusa
      REDIS_URL: redis://redis:6379
      JWT_SECRET: ${JWT_SECRET}
      COOKIE_SECRET: ${COOKIE_SECRET}
    depends_on:
      - db
      - redis
    restart: unless-stopped

  db:
    image: postgres:14-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: medusa
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    restart: unless-stopped

  redis:
    image: redis:6-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    restart: unless-stopped

volumes:
  postgres_data:
  redis_data:

相关资源链接

官方资源:

Medusa 官方文档: https://docs.medusajs.com
GitHub 仓库: https://github.com/medusajs/medusa
官方插件列表: https://github.com/medusajs/medusa-plugins
Medusa Discord: https://discord.gg/medusajs

前端模板:

Next.js 官方模板: https://github.com/medusajs/nextjs-starter-medusa
Gatsby 模板: https://github.com/medusajs/gatsby-starter-medusa
Nuxt.js 模板: https://github.com/medusajs/nuxt-starter-medusa

学习资源:

官方教程视频: https://www.youtube.com/@Medusajs
Medusa 博客: https://medusajs.com/blog/
社区论坛: https://forum.medusajs.com

第三方插件推荐:

支付插件:
- Stripe: @medusajs/medusa-payment-stripe
- PayPal: @medusajs/medusa-payment-paypal
- Alipay: @medusajs/medusa-payment-alipay
- WeChat Pay: @medusajs/medusa-payment-wechatpay

搜索插件:
- Meilisearch: @medusajs/meilisearch
- Algolia: @medusajs/algolia

通知插件:
- SendGrid: @medusajs/medusa notification-sendgrid
- Twilio: @medusajs/medusa- notification-twilio

文件存储:
- S3: @medusajs/medusa file-s3
- MinIO: @medusajs/medusa-file-minio

总结

Medusa 代表了现代电商平台开发的新范式。它通过开源、模块化和高度可定制的架构设计,让开发者能够摆脱传统 SaaS 平台的限制,构建真正符合业务需求的电商解决方案。

核心要点回顾:

技术栈优势:
├── Node.js + TypeScript = 开发效率与类型安全
├── Headless 架构 = 前端技术栈完全自由
├── 插件系统 = 功能扩展轻而易举
└── RESTful API = 前后端分离,无缝集成

实用特性:
├── 完整的后台管理界面
├── 灵活的促销系统
├── 多区域多货币支持
├── 订单全生命周期管理
└── 丰富的官方和社区插件

适用场景:
├── 初创公司快速 MVP
├── 中大型企业数字化转型
├── B2B 批发平台
├── 订阅制电商
├── 多品牌多店铺平台
└── 全球化电商平台

无论你是独立开发者还是企业团队,Medusa 都提供了一个可靠的基础架构,让你能够专注于创造独特的用户体验,而不必从零开始构建基础设施。

立即开始你的 Medusa 之旅吧!

如果内容对您有帮助,欢迎打赏

您的支持是我继续创作的动力

前往打赏页面

评论区

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注