别再为电商系统头疼了,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 之旅吧!
评论区