Use Cases (更新: 2026/6/1)

用Claude Code构建电商商店:Next.js、Stripe Checkout与库存管理

用Claude Code实现电商核心流程:商品、购物车、库存、Stripe Checkout、Webhook、后台、SEO、数据分析与售后。

用Claude Code构建电商商店:Next.js、Stripe Checkout与库存管理

Claude Code在电商项目中应该负责什么

一个电商站并不是把商品卡片和付款按钮放上去就完成了。真正可以上线的流程,需要商品列表、商品详情、购物车、库存锁定、订单创建、Stripe Checkout、Webhook确认付款、后台管理、SEO、数据分析、退货和取消订单一起工作。

Claude Code的价值不只是帮你快速写组件,而是把这些业务边界拆成可以审查的实现任务。Masa做小型实物商品演示时,曾经把“成功页打开”当成订单确认依据。后来发现客户付款后可能关闭浏览器,Webhook也可能重试,Checkout过期还要释放库存。更稳妥的提示词是:付款确认以Webhook为准,价格和库存必须在服务器端重新计算。

本文使用Next.js App Router和TypeScript。示例代码用内存数据结构,方便本地复制测试;生产环境请换成PostgreSQL、Prisma或你已有的订单数据库。支付部分请对照Stripe官方的Checkout Sessions APICheckout fulfillment指南,Next.js部分参考Route HandlersMetadata API

如果你要继续深入,可以阅读Claude Code实现Stripe CheckoutClaude 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,后台发货和取消按钮只在正确状态下可用。

#Claude Code #电商 #Stripe #Next.js #TypeScript
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。