用Claude Code构建电商商店:Next.js、Stripe Checkout与库存管理
用Claude Code实现电商核心流程:商品、购物车、库存、Stripe Checkout、Webhook、后台、SEO、数据分析与售后。
Claude Code在电商项目中应该负责什么
一个电商站并不是把商品卡片和付款按钮放上去就完成了。真正可以上线的流程,需要商品列表、商品详情、购物车、库存锁定、订单创建、Stripe Checkout、Webhook确认付款、后台管理、SEO、数据分析、退货和取消订单一起工作。
Claude Code的价值不只是帮你快速写组件,而是把这些业务边界拆成可以审查的实现任务。Masa做小型实物商品演示时,曾经把“成功页打开”当成订单确认依据。后来发现客户付款后可能关闭浏览器,Webhook也可能重试,Checkout过期还要释放库存。更稳妥的提示词是:付款确认以Webhook为准,价格和库存必须在服务器端重新计算。
本文使用Next.js App Router和TypeScript。示例代码用内存数据结构,方便本地复制测试;生产环境请换成PostgreSQL、Prisma或你已有的订单数据库。支付部分请对照Stripe官方的Checkout Sessions API和Checkout fulfillment指南,Next.js部分参考Route Handlers与Metadata API。
如果你要继续深入,可以阅读Claude Code实现Stripe Checkout、Claude Code自动化SEO优化以及Claude Code开发后台仪表盘。
先画清楚整体架构
电商实现要把四件事分开:创建订单、锁定库存、确认付款、付款后的运营处理。这样交给Claude Code时不会混在一起,人工审查也更容易。
flowchart LR
A["商品列表与详情"] --> B["购物车"]
B --> C["创建订单API"]
C --> D["库存锁定"]
D --> E["Stripe Checkout Session"]
E --> F["Webhook"]
F --> G["订单确认与履约队列"]
G --> H["后台管理"]
H --> I["发货、退货、取消"]
A --> J["SEO与结构化数据"]
B --> K["数据分析事件"]
给Claude Code的第一条提示词要写业务约束,而不是只说“做一个电商网站”。
请用Next.js App Router和TypeScript实现一个小型电商商店。
请分开实现商品列表、购物车、库存锁定、Stripe Checkout Session创建、
Webhook确认订单、后台发货和取消订单。
不要信任客户端传来的价格和库存。
Webhook必须验证Stripe签名,并处理checkout.session.completed和async_payment_succeeded。
不要因为用户访问success_url就把订单标记为已付款。
商品、库存与订单模型
下面的模块可以直接放到本地项目中测试。它使用内存Map保存订单和库存,所以不适合生产环境;上线前请替换成持久数据库。重点是业务边界:价格、库存、订单状态都由服务器端控制。
// src/lib/store.ts
export type Product = {
id: string;
slug: string;
name: string;
description: string;
priceJPY: number;
stock: number;
active: boolean;
image: string;
};
export type CartLine = {
productId: string;
quantity: number;
};
export type OrderLine = CartLine & {
name: string;
unitAmount: number;
};
export type OrderStatus = "pending" | "paid" | "shipped" | "canceled" | "refunded";
export type Order = {
id: string;
lines: OrderLine[];
amountTotal: number;
status: OrderStatus;
reserved: boolean;
stripeSessionId?: string;
customerEmail?: string;
createdAt: string;
updatedAt: string;
};
const products: Product[] = [
{
id: "tea-001",
slug: "roasted-green-tea",
name: "焙茶礼盒",
description: "适合首次购买的礼盒套装。",
priceJPY: 3200,
stock: 12,
active: true,
image: "/images/products/tea.jpg",
},
{
id: "mug-001",
slug: "ceramic-mug",
name: "手作陶瓷杯",
description: "小批量陶瓷杯,退货入库前需要检查破损。",
priceJPY: 4800,
stock: 6,
active: true,
image: "/images/products/mug.jpg",
},
];
const stock = new Map<string, number>(products.map((product) => [product.id, product.stock]));
const orders = new Map<string, Order>();
export function listProducts(): Product[] {
return products
.filter((product) => product.active)
.map((product) => ({ ...product, stock: stock.get(product.id) ?? 0 }));
}
export function getProduct(productIdOrSlug: string): Product | undefined {
return listProducts().find(
(product) => product.id === productIdOrSlug || product.slug === productIdOrSlug,
);
}
function normalizeLines(lines: CartLine[]): CartLine[] {
const merged = new Map<string, number>();
for (const line of lines) {
if (!Number.isInteger(line.quantity) || line.quantity < 1 || line.quantity > 20) {
throw new Error("数量必须在1到20之间。");
}
merged.set(line.productId, (merged.get(line.productId) ?? 0) + line.quantity);
}
return Array.from(merged, ([productId, quantity]) => ({ productId, quantity }));
}
function requireOrder(orderId: string): Order {
const order = orders.get(orderId);
if (!order) throw new Error("订单不存在。");
return order;
}
export function createPendingOrder(lines: CartLine[]): Order {
const normalized = normalizeLines(lines);
const orderLines = normalized.map((line) => {
const product = getProduct(line.productId);
if (!product) throw new Error(`商品不存在: ${line.productId}`);
const availableStock = stock.get(product.id) ?? 0;
if (availableStock < line.quantity) {
throw new Error(`${product.name}库存不足。`);
}
return {
productId: product.id,
quantity: line.quantity,
name: product.name,
unitAmount: product.priceJPY,
};
});
const now = new Date().toISOString();
const order: Order = {
id: crypto.randomUUID(),
lines: orderLines,
amountTotal: orderLines.reduce((sum, line) => sum + line.unitAmount * line.quantity, 0),
status: "pending",
reserved: false,
createdAt: now,
updatedAt: now,
};
orders.set(order.id, order);
return order;
}
export function reserveOrderStock(orderId: string): Order {
const order = requireOrder(orderId);
if (order.reserved) return order;
for (const line of order.lines) {
const availableStock = stock.get(line.productId) ?? 0;
if (availableStock < line.quantity) {
throw new Error(`${line.name}库存不足。`);
}
}
for (const line of order.lines) {
stock.set(line.productId, (stock.get(line.productId) ?? 0) - line.quantity);
}
order.reserved = true;
order.updatedAt = new Date().toISOString();
return order;
}
export function attachStripeSession(orderId: string, stripeSessionId: string): Order {
const order = requireOrder(orderId);
order.stripeSessionId = stripeSessionId;
order.updatedAt = new Date().toISOString();
return order;
}
export function fulfillPaidOrder(input: {
orderId: string;
stripeSessionId: string;
customerEmail?: string;
}): Order {
const order = requireOrder(input.orderId);
if (order.status === "paid" || order.status === "shipped") return order;
if (!order.reserved) reserveOrderStock(order.id);
order.status = "paid";
order.stripeSessionId = input.stripeSessionId;
order.customerEmail = input.customerEmail;
order.updatedAt = new Date().toISOString();
return order;
}
export function markOrderShipped(orderId: string): Order {
const order = requireOrder(orderId);
if (order.status !== "paid") throw new Error("只有已付款订单可以发货。");
order.status = "shipped";
order.updatedAt = new Date().toISOString();
return order;
}
export function cancelOrder(orderId: string, reason = "customer_canceled"): Order {
const order = requireOrder(orderId);
if (order.status === "canceled" || order.status === "refunded") return order;
if (order.status === "pending" && order.reserved) {
for (const line of order.lines) {
stock.set(line.productId, (stock.get(line.productId) ?? 0) + line.quantity);
}
order.reserved = false;
}
order.status = "canceled";
order.updatedAt = new Date().toISOString();
console.info(`Order ${order.id} canceled: ${reason}`);
return order;
}
export function markOrderRefunded(orderId: string): Order {
const order = requireOrder(orderId);
if (order.status !== "paid" && order.status !== "shipped") {
throw new Error("只有已付款或已发货订单可以标记退款。");
}
order.status = "refunded";
order.updatedAt = new Date().toISOString();
return order;
}
export function listOrders(): Order[] {
return Array.from(orders.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
审查这段代码时,要让Claude Code重点检查数量上限、库存不足、Webhook重复触发时是否会重复履约,以及取消订单是否只恢复未付款订单的库存。
商品列表与购物车
客户端可以显示小计,但不能决定最终金额。提交给服务器的只应该是商品ID和数量。
// src/components/product-grid-with-cart.tsx
"use client";
import { useMemo, useState } from "react";
import type { CartLine, Product } from "@/lib/store";
type CheckoutResponse = {
url?: string;
error?: string;
};
export function ProductGridWithCart({ products }: { products: Product[] }) {
const [cart, setCart] = useState<CartLine[]>([]);
const [loading, setLoading] = useState(false);
const subtotal = useMemo(() => {
return cart.reduce((sum, line) => {
const product = products.find((item) => item.id === line.productId);
return sum + (product?.priceJPY ?? 0) * line.quantity;
}, 0);
}, [cart, products]);
function addToCart(productId: string) {
setCart((current) => {
const existing = current.find((line) => line.productId === productId);
if (existing) {
return current.map((line) =>
line.productId === productId ? { ...line, quantity: line.quantity + 1 } : line,
);
}
return [...current, { productId, quantity: 1 }];
});
}
async function checkout() {
try {
setLoading(true);
const response = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lines: cart }),
});
const data = (await response.json()) as CheckoutResponse;
if (!response.ok || !data.url) {
throw new Error(data.error ?? "无法开始结账。");
}
window.location.href = data.url;
} catch (error) {
alert(error instanceof Error ? error.message : "结账失败。");
} finally {
setLoading(false);
}
}
return (
<div className="grid gap-8 lg:grid-cols-[1fr_320px]">
<div className="grid gap-6 sm:grid-cols-2">
{products.map((product) => (
<article key={product.id} className="rounded-lg border p-4">
<img src={product.image} alt={product.name} className="aspect-square w-full object-cover" />
<h2 className="mt-3 text-lg font-semibold">{product.name}</h2>
<p className="mt-1 text-sm text-gray-600">{product.description}</p>
<p className="mt-3 font-bold">JPY {product.priceJPY.toLocaleString()}</p>
<p className="text-sm text-gray-500">库存: {product.stock}</p>
<button
type="button"
disabled={product.stock < 1}
onClick={() => addToCart(product.id)}
className="mt-4 w-full rounded bg-black px-4 py-2 text-white disabled:bg-gray-300"
>
加入购物车
</button>
</article>
))}
</div>
<aside className="h-fit rounded-lg border p-4">
<h2 className="text-lg font-semibold">购物车</h2>
{cart.length === 0 ? (
<p className="mt-3 text-sm text-gray-500">购物车为空。</p>
) : (
<ul className="mt-3 space-y-2">
{cart.map((line) => {
const product = products.find((item) => item.id === line.productId);
return (
<li key={line.productId} className="flex justify-between text-sm">
<span>{product?.name}</span>
<span>{line.quantity}件</span>
</li>
);
})}
</ul>
)}
<p className="mt-4 font-bold">小计: JPY {subtotal.toLocaleString()}</p>
<button
type="button"
disabled={cart.length === 0 || loading}
onClick={checkout}
className="mt-4 w-full rounded bg-blue-600 px-4 py-2 text-white disabled:bg-gray-300"
>
{loading ? "跳转中..." : "前往Stripe Checkout"}
</button>
</aside>
</div>
);
}
创建Stripe Checkout Session
Checkout Session创建前先生成订单并锁定库存。金额必须从服务器端商品数据计算,内部订单ID放入metadata。不要把地址、证件号、卡信息等敏感信息放进metadata。
// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import {
attachStripeSession,
createPendingOrder,
getProduct,
reserveOrderStock,
} from "@/lib/store";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: NextRequest) {
try {
const { lines } = (await request.json()) as {
lines: { productId: string; quantity: number }[];
};
const order = createPendingOrder(lines);
reserveOrderStock(order.id);
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: order.lines.map((line) => {
const product = getProduct(line.productId);
if (!product) throw new Error(`商品不存在: ${line.productId}`);
return {
price_data: {
currency: "jpy",
product_data: {
name: product.name,
images: [`${process.env.NEXT_PUBLIC_APP_URL}${product.image}`],
metadata: { productId: product.id },
},
unit_amount: product.priceJPY,
},
quantity: line.quantity,
};
}),
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/cart?canceled=1`,
shipping_address_collection: {
allowed_countries: ["JP"],
},
metadata: {
orderId: order.id,
},
});
attachStripeSession(order.id, session.id);
return NextResponse.json({ url: session.url }, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "结账失败。" },
{ status: 400 },
);
}
}
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_APP_URL=http://localhost:3000
常见错误是用浏览器传来的小计生成unit_amount。这个值必须来自服务器。另一个运营问题是,用户进入Checkout后没有付款,库存会被锁住,所以要处理Session过期后的库存恢复。
用Webhook确认订单
成功页不是订单确认依据。客户付款后可能直接关闭页面,网络也可能中断。Webhook才是付款确认的可靠入口,并且处理函数必须能承受重复事件。
// src/app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { cancelOrder, fulfillPaidOrder } from "@/lib/store";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing Stripe signature" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Invalid webhook" },
{ status: 400 },
);
}
if (
event.type === "checkout.session.completed" ||
event.type === "checkout.session.async_payment_succeeded"
) {
const session = event.data.object as Stripe.Checkout.Session;
const orderId = session.metadata?.orderId;
if (orderId && session.payment_status === "paid") {
fulfillPaidOrder({
orderId,
stripeSessionId: session.id,
customerEmail: session.customer_details?.email ?? undefined,
});
}
}
if (
event.type === "checkout.session.expired" ||
event.type === "checkout.session.async_payment_failed"
) {
const session = event.data.object as Stripe.Checkout.Session;
const orderId = session.metadata?.orderId;
if (orderId) cancelOrder(orderId, event.type);
}
return NextResponse.json({ received: true });
}
stripe listen --forward-to localhost:3000/api/stripe/webhook
审查点包括签名验证、payment_status检查、异步支付成功和失败、库存恢复,以及幂等性。幂等性就是同一个事件重复执行也不会造成重复发货或重复扣库存。
后台、退货与取消订单
后台不是可有可无的页面。运营人员需要查看已付款订单、标记发货、取消未付款订单、识别退款订单。上线前还必须加认证和权限控制。
// src/app/admin/orders/page.tsx
import { cancelOrder, listOrders, markOrderShipped } from "@/lib/store";
async function shipOrder(formData: FormData) {
"use server";
markOrderShipped(String(formData.get("orderId")));
}
async function cancelPendingOrder(formData: FormData) {
"use server";
cancelOrder(String(formData.get("orderId")), "admin_canceled");
}
export default function AdminOrdersPage() {
const orders = listOrders();
return (
<main className="mx-auto max-w-5xl p-6">
<h1 className="text-2xl font-bold">订单管理</h1>
<div className="mt-6 overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-2">订单ID</th>
<th className="py-2">状态</th>
<th className="py-2">金额</th>
<th className="py-2">邮箱</th>
<th className="py-2">操作</th>
</tr>
</thead>
<tbody>
{orders.map((order) => (
<tr key={order.id} className="border-b">
<td className="py-3 font-mono text-xs">{order.id}</td>
<td className="py-3">{order.status}</td>
<td className="py-3">JPY {order.amountTotal.toLocaleString()}</td>
<td className="py-3">{order.customerEmail ?? "-"}</td>
<td className="flex gap-2 py-3">
<form action={shipOrder}>
<input type="hidden" name="orderId" value={order.id} />
<button type="submit" disabled={order.status !== "paid"} className="rounded bg-black px-3 py-1 text-white disabled:bg-gray-300">
标记发货
</button>
</form>
<form action={cancelPendingOrder}>
<input type="hidden" name="orderId" value={order.id} />
<button type="submit" disabled={order.status !== "pending"} className="rounded border px-3 py-1 disabled:text-gray-300">
取消
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</main>
);
}
退货规则要先于自动化确定。发货前取消可以恢复库存;发货后退货需要检查商品状态,决定重新入库、折价销售或报废。即使退款先在Stripe Dashboard手动操作,也要把结果写回订单状态。
SEO与数据分析
每个商品页都可能获得搜索流量。页面应包含商品名、用途、材质、配送条件、退货说明、OGP图片和canonical。分析事件则建议统一为浏览商品、加入购物车、开始结账、购买完成。
// src/app/products/[slug]/metadata.ts
import type { Metadata } from "next";
import { getProduct } from "@/lib/store";
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const product = getProduct(params.slug);
if (!product) {
return {
title: "商品不存在",
robots: { index: false, follow: false },
};
}
const title = `${product.name} | ClaudeCodeLab Store`;
const description = `${product.description} 价格为JPY ${product.priceJPY.toLocaleString()}。购买前可确认库存、配送和退货条件。`;
return {
title,
description,
alternates: {
canonical: `/products/${product.slug}`,
},
openGraph: {
title,
description,
images: [product.image],
type: "website",
},
};
}
// src/lib/analytics.ts
type CommerceEventName = "view_item" | "add_to_cart" | "begin_checkout" | "purchase";
type CommercePayload = {
currency: "JPY";
value: number;
items: Array<{
item_id: string;
item_name: string;
price: number;
quantity: number;
}>;
};
declare global {
interface Window {
dataLayer?: unknown[];
}
}
export function trackCommerceEvent(name: CommerceEventName, payload: CommercePayload) {
if (typeof window === "undefined") return;
window.dataLayer = window.dataLayer ?? [];
window.dataLayer.push({
event: name,
ecommerce: payload,
});
}
三个实际场景
D2C品牌首发时,商品数量少,优先级不是复杂搜索,而是库存锁定、Checkout、Webhook和发货后台。社交媒体带来的瞬时流量容易造成同时购买,服务器端库存控制比漂亮的库存标签更重要。
数字教材或PDF销售不需要物流,但仍然需要履约。不要只在成功页显示下载链接。应该由Webhook确认付款,再授予下载权限。
B2B小批量订货经常需要人工审核。可以保留Stripe Checkout给信用卡付款,同时在后台增加“报价中”“已发送发票”“已确认入金”等状态。
| 场景 | 最优先实现 | 注意点 |
|---|---|---|
| D2C首发 | 库存锁定、Checkout、发货后台 | 活动流量可能同时下单 |
| 数字商品 | Webhook后授权下载 | 不要只靠成功页发放 |
| B2B订货 | 后台、报价、状态流转 | 可能仍需人工确认 |
具体失败例
最常见的失败是访问success_url就把订单标记为已付款。客户可能付款成功但没有回到网站,所以订单确认必须依赖Webhook。
第二个失败是信任浏览器里的购物车金额。浏览器JavaScript可以被修改,折扣和运费也可能被伪造。服务器必须重新计算价格、优惠、配送费和库存。
库存也容易出问题。Checkout开始就锁库存,会让未付款订单占住库存;付款后才扣库存,又可能卖超。现实做法是短时间锁定、过期释放,并提供后台手动调整。
安全方面,Stripe秘密钥匙暴露、Webhook不验签、后台无认证、metadata里塞个人信息,都会造成严重事故。让Claude Code实现前,要把这些权限和安全边界写清楚。
让Claude Code做代码审查
请审查这个电商实现。
重点检查价格篡改、库存重复锁定、Webhook重试导致重复履约、
未付款订单被发货、取消订单时库存恢复、后台权限、
Stripe secret和webhook secret泄露、metadata混入个人信息。
每个问题请给出文件名、函数名、复现步骤和修复方案。
人工验证时,要测试成功付款、认证失败、Checkout过期、Webhook重放、发货按钮重复点击、退款状态显示。
总结
Claude Code可以帮助你快速搭建电商基础,但前提是把商品、购物车、库存、订单、Stripe Checkout、Webhook、后台、SEO、分析、退货和取消订单作为同一条业务链设计。真正决定能否上线的是三点:Webhook确认付款,服务器端重新计算价格和库存,重复事件不会破坏状态。
ClaudeCodeLab可以协助团队做电商原型、Stripe Checkout集成、后台管理、SEO改善,以及Claude Code团队培训。准备好当前应用、商品规则和运营限制后,实施计划会更清晰。
实际试用本文代码时,请确认stripe listen能收到事件,测试卡付款会产生checkout.session.completed,成功页关闭后订单仍会变成已付款,库存不足时不会创建Checkout Session,后台发货和取消按钮只在正确状态下可用。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。