你是否曾经好奇过,像Etsy、Uber或Teachable这样的平台是如何为成千上万的卖家处理支付事务的?答案就是多卖家市场平台:这种平台上,商家可以注册账号、列出自己提供的产品或服务,并直接从顾客那里收取款项。
在本手册中,你将使用TypeScript从零开始构建一个完整的市场平台。你不需要使用传统的数据库,而是会利用Stripe来管理产品目录和处理支付事务。
许多现实世界中的市场平台都是这样运作的:Stripe负责存储产品信息、价格以及顾客数据,而你的应用程序则负责提供用户友好的界面。
你将会构建以下内容:
-
商家注册流程,帮助卖家创建账户并连接Stripe系统
-
产品管理系统,让商家能够直接通过Stripe添加和列出产品
-
结账流程,支持一次性支付和定期订阅两种支付方式
-
实时监听支付事件的Webhook功能
-
顾客可以用来管理自己订阅服务的计费门户
-
供顾客浏览和购买产品的完整购物界面
你还可以从文末提供的GitHub仓库中获取完整的源代码。
目录
先决条件
在开始之前,请确保您具备以下条件:
-
您的机器上已安装Node.js(版本18或更高)。
-
您需要对React、TypeScript和REST API有基本的了解。
-
您需要一个Stripe账户(可以在stripe.com免费注册)。
-
您需要一个代码编辑器,例如VS Code。
对于这个项目来说,不需要数据库。Stripe会负责存储您的产品信息、价格以及客户资料。这样的设计使得系统架构更为简单,同时也符合许多实际运营中的市场平台的运作方式。
什么是Stripe Connect?
Stripe Connect是一组专为各种平台和市场设计的API。它允许您为商家创建账户(Stripe将这些账户称为“关联账户”),将付款请求路由到这些账户,并从每笔交易中收取平台费用。
在本教程中,您将使用Stripe的V2 Accounts API。这是创建关联账户的较新且推荐的方式。通过V2 API,您可以通过配置对象来指定每个账户可以执行哪些操作(例如接受信用卡付款、接收款项),而Stripe会负责处理所有的合规性检测和身份验证流程。
付款流程的具体步骤如下:
-
顾客在您的市场平台上选择商品并点击“结账”按钮。
-
您的服务器会创建一个与商家关联账户相关联的Stripe结算会话。
-
顾客会在Stripe提供的结算页面上进行支付。
-
Stripe会自动分配付款金额:商家会收到属于他们的部分,而您的平台也会收取相应的服务费用。
-
Stripe会向您的服务器发送一个Webhook通知,确认付款已经完成。
-
商家可以通过自己的Stripe管理面板查看收入并提取资金。
如何设置项目
创建一个项目文件夹,并为后端和前端分别设立不同的目录:
mkdir marketplace && cd marketplace
mkdir server client
如何设置后端
进入server目录,然后初始化一个TypeScript项目:
cd server
npm init -y
npm install express cors dotenv stripe
npm install -D typescript ts-node @types/express @types/cors @types/node
npx tsc --init
mkdir src
打开tsconfig.json文件,并将其配置内容更新为以下内容:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
然后在服务器的根目录下创建一个.env文件:
STRIPE_SECRET_KEY=sk_test_your_key_here
DOMAIN=http://localhost:3000
你可以在Stripe的控制面板中,通过“开发人员”>“API密钥”来找到自己的测试秘钥。DOMAIN变量用于指定结账后将客户重定向到哪个地址。
将这些脚本添加到你的package.json文件中:
{
"scripts": {
"dev": "ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
如何构建Express后端
创建src/index.ts文件。这个文件将构成你的整个后端代码。我们先从配置和导入模块开始:
import express, { Request, Response, Router } from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import Stripe from 'stripe';
dotenv.config();
const app = express();
const router = Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);
app.use(cors({ origin: process.env.DOMAIN }));
app.use(express.static('public'));
请注意,我们并没有导入任何数据库客户端。Stripe就是我们的数据存储层。所有的产品信息、价格、客户资料以及交易记录都保存在Stripe系统中。你的Express服务器只是一个负责协调前后端交互的中间层,它会代表前端与Stripe API进行通信。
我们还添加了express.static("public")这一行代码,这样在需要时就可以提供静态文件了。由于Webhook端点需要接收原始的请求体数据,因此我们需要在解析JSON数据之前先注册这个端点。现在就来添加这部分代码吧。
如何处理商家的入驻流程
商家首先需要在你们的平台上创建一个账户,并将其与Stripe系统连接起来。这个过程包括两个步骤:首先是创建一个关联账户,然后会将商家重定向到Stripe提供的入驻表格填写页面。
如何创建关联账户
在src/index.ts文件中添加以下路由代码:
// 请求体的类型定义
interface CreateAccountBody {
email: string;
}
interface AccountIdBody {
accountId: string;
}
// 使用Stripe V2 API创建关联账户
router.post(
'/create-connect-account',
async (req: Request<{}, {}, CreateAccountBody>, res: Response) => {
try {
const account = await stripe.v2.core.accounts.create({
display_name: req.body.email,
contact_email: req.body.email,
dashboard: 'full',
defaults: {
responsibilities: {
fees_collector: 'stripe',
losses_collector: 'stripe',
},
},
identity: {
country: 'GB',
entity_type: 'company',
},
configuration: {
customer: {},
merchant: {
capabilities: {
card_payments: { requested: true },
},
},
},
});
res.json({ accountId: account.id });
} catch (error) {
const message = error instanceof Error ? error.message : '未知错误';
res.status(500).json({ error: message });
}
},
);
让我们来详细分析一下这段代码的功能。stripe.v2.core.accounts.create()方法使用Stripe的V2 API创建一个新的关联账户。以下是其中一些关键的配置选项:
-
dashboard: "full"允许商家访问自己的Stripe管理面板,在那里他们可以查看交易记录、管理付款事宜以及处理纠纷。 -
responsibilities用于指定谁负责收取费用,以及谁需要承担损失。将这两项都设置为“stripe”意味着由Stripe来处理这些事务,这是最简单的配置方式。 -
identity用于设置国家代码和实体类型。请将“GB”替换为商家的实际国家代码(例如,美国的代码是“US”)。 -
configuration.merchant.capabilities请求启用card_payments功能,这样商家就可以接受信用卡付款了。
如何创建注册链接
创建账户后,你需要将商家重定向到Stripe提供的注册页面。请添加以下路由:
// 用于生成注册链接的路由
router.post('/create-account-link', async (req: Request<{}, {}, AccountIdBody>, res: Response) => {
const { accountId } = req.body;
try {
const accountLink = await stripe.v2.core.accountLinks.create({
account: accountId,
use_case: {
type: 'account_onboarding',
account_onboarding: {
configurations: ['merchant', 'customer'],
refresh_url: `${process.env.DOMAIN}`,
return_url: `\({process.env.DOMAIN}?accountId=\){AccountId`),
},
},
});
res.json({ url: accountLink.url });
} catch (error) {
const message = error instanceof Error ? error.message : '未知错误';
res.status(500).json({ error: message });
}
});
accountLinks.create()方法会生成一个临时链接,该链接会将商家导向Stripe的注册页面。在那个页面上,Stripe会收集商家的身份证明文件、银行账户信息以及税务相关资料。你无需自己编写这些处理逻辑。
return_url是指当商家完成注册流程后,Stripe会将其重定向到的地址。请注意,在这个链接中添加了accountId作为查询参数,这样你的前端程序就能获取并存储这一信息。
如何查看账户状态
你需要一种方法来确认商家是否已经完成注册流程,并且可以开始接受付款了。请添加以下路由:
// 用于查询账户状态的路由
router.get(
'/account-status/:accountId',
async (req: Request<{ accountId: string }>, res: Response) => {
try {
const account = await stripe.v2.core.accounts.retrieve(req.params.accountId, {
include: ['requirements', 'configuration.merchant'],
});
const payoutsEnabled =
account.configuration?.merchant?.capabilities?.stripe_balance?.payouts?.status === 'active';
const chargesEnabled =
account/configuration?.merchant?.capabilities?.card Payments?.status === 'active';
const summaryStatus = account.requirements?.summary?.minimum_deadline?.status;
const detailsSubmitted = !summaryStatus || summaryStatus === 'eventually_due';
res.json({
id: account.id,
payoutsEnabled,
chargesEnabled,
details Submitted,
requirements: account.requirements?.entries,
});
} catch (error) {
const message = error instanceof Error ? error.message : '未知错误';
res.status(500).json({ error: message });
}
},
);
此路由会获取关联的账户信息,并检查三个重要的状态:
-
chargesEnabled用于指示商家是否能够接受付款。 -
payoutsEnabled用于说明商家是否能够将款项接收至其银行账户。 -
detailsSubmitted表示商家是否已经完成了注册表格的填写。
您的前端程序会利用这些状态标志来决定是显示还是隐藏相应的功能。
如何通过Stripe创建产品
您无需将产品信息存储在数据库中,而是可以直接在Stripe平台上创建它们。每个产品都是使用stripeAccount参数在商家的关联账户中创建的。这意味着每位商家在Stripe系统中都拥有自己独立的产品目录。
// 用于创建产品的类型定义
interface CreateProductBody {
productName: string;
productDescription: string;
productPrice: number;
accountId: string;
}
// 在关联账户上创建产品
router.post('/create-product', async (req: Request<{}, {}, CreateProductBody>, res: Response) => {
const { productName, productDescription, productPrice, accountId } = req.body;
try {
// 在关联账户上创建产品
const product = await stripe.products.create(
{
name: productName,
description: productDescription,
},
{ stripeAccount: accountId },
);
// 为该产品设置价格
const price = await stripe.prices.create(
{
product: product.id,
unitAmount: productPrice,
currency: 'usd',
},
{ stripeAccount: accountId },
);
res.json({
productName,
productDescription,
productPrice,
priceId: price.id,
});
} catch (error) {
const message = error instanceof Error ? error.message : '未知错误';
res.status(500).json({ error: message });
}
});
这里发生了两次Stripe API调用。首先,stripe.products.create()用于创建产品的名称和描述信息;然后,stripe.prices.create()用于为该产品设置价格。
Stripe将产品和价格分开处理,因为一个产品可以有多种价格选项——例如,月度套餐和年度套餐。
在这两次调用中,{ stripeAccount: accountId }这一参数告诉Stripe将这些资源创建在商家的关联账户上,而不是在您的平台账户上。这是一个非常重要的细节:如果没有这个参数,产品将会被创建在您的平台账户上,商家就无法看到这些产品。
如何获取产品信息
添加一条路由来列出特定商家的所有产品:
// 为指定账户获取产品信息
router.get('/products/:accountId', async (req: Request<{"accountId": string}> & res: Response) => {
const { accountId } = req.params;
try {
const options: Stripe.RequestOptions = {};
if (accountId !== 'platform') {
options.stripeAccount = accountId;
}
const prices = await stripe.prices.list(
{
expand: ['data.product'],
active: true,
limit: 100,
},
options,
);
const products = prices.data.map((price) => {
const product = price.product as Stripe.Product;
return {
id: product.id,
name: product.name,
description: product.description,
price: price.unitAmount,
priceId: price.id,
period: price.recurring ? price.recurring.interval : null,
};
});
res.json(products);
} catch (error) {
const message = error instanceof Error ? error.message : '未知错误';
res.status(500).json({ error: message });
}
});
此路由会从商家的Stripe账户中获取所有有效价格信息,并扩展产品数据(通过使用expand: ["data.product"]),这样你就可以在同一个API调用中获得产品的名称和描述。对于一次性购买的产品,`period`字段将为`null`;而对于订阅服务,则该字段值为`month`或`year`。
如何构建结账流程
你的结账流程需要处理两种情况:针对单个产品的一次性付款,以及定期订阅服务。Stripe的结账会话功能可以同时处理这两种情况——你只需要根据价格类型来设置相应的模式即可。
// 结账相关的类型定义
interface CheckoutBody {
priceId: string;
accountId: string;
}
// 创建结账会话
router.post(
'/create-checkout-session',
async (req: Request<{}, {}, CheckoutBody>, res: Response) => {
const { priceId, accountId } = req.body;
try {
// 获取价格信息,以确定它是属于一次性付款还是定期订阅
const price = await stripe.prices.retrieve(priceId, { stripeAccount: accountId });
const isSubscription = price.type === 'recurring';
const mode = isSubscription ? 'subscription' : 'payment';
const session = await stripe.checkout_sessions.create(
{
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode,
success_url: `${process.env.DOMAIN}/done?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.DOMAIN]",
...(isSubscription
? {
subscription_data: {
application_fee_percent: 10,
},
}
: {
payment_intent_data: {
applicationFee_amount: 123,
},
}),
},
{ stripeAccount: accountId },
);
res.redirect(303, session.url as string);
} catch (error) {
const message = error instanceof Error ? error.message : '未知错误';
res.status(500).json({ error: message });
}
},
);
下面逐步说明这个路由的具体工作流程:首先,它会从商家的Stripe账户中获取价格信息,从而判断该价格是一次性付款还是定期订阅费用;随后,它会根据价格类型创建相应的结账会话——要么是“payment”模式,要么是“subscription”模式。
application_fee_amount代表你的平台从每笔交易中抽取的手续费,这个数值以最小的货币单位来表示(例如,对于美元来说就是以美分为单位)。在这个例子中,每笔交易的手续费为1.23美元或10%;而在实际的市场环境中,你通常会将这笔手续费计算为产品价格的百分比。
需要注意的是,对于定期订阅服务而言,application_fee_amount会被放在subscription_data字段中;而对于一次性付款来说,则会被放在payment_intent_data字段中。这是Stripe规定的要求——这两种不同的支付模式需要使用不同的配置对象。
最后,该路由使用res.redirect(303, session.url)将客户直接引导至Stripe提供的结账页面。
如何处理Webhook
Webhook是 Stripe用来向您的服务器通知那些异步发生的事件的一种方式——比如支付成功、充值失败或订阅被取消等。
在正式运行的市场中,您绝对不能仅依赖重定向URL来确认付款情况。因为客户可能在重定向完成之前就关闭了浏览器。因此,Webhook才是获取这些信息的关键来源。
请在解析JSON数据之前添加Webhook处理端点。Stripe发送的Webhook数据是以原始字节的形式传递的,而您需要这些原始数据才能验证签名:
// 重要提示:必须在app.use(express.json())之前注册此处理函数
app.post(
'/api/webhook',
express.raw({ type: 'application/json' }),
(req: Request, res: Response) => {
let event: Stripe.Event = JSON.parse(req.body.toString()); // 如果您设置了端点密钥,请进行签名验证以确保安全性
const endpointSecret = process.env.WEBHOOK_SECRET;
if (endpointSecret) {
const signature = req.headers['stripe-signature'] as string;
try {
event = stripe.webhooksconstructEvent(req.body, signature, endpointSecret) as Stripe.Event;
} catch (err) {
const message = err instanceof Error ? err.message : '未知错误';
console.log('Webhook签名验证失败:', message);
res.status(400);
return;
}
} // 处理相应的事件
switch (event.type) {
case 'checkout.session_completed': {
const session = event.data.object as Stripe.Checkout.Session;
console.log('该会话的支付操作已成功完成:', session.id); // 现在可以执行订单完成流程:发送邮件通知客户、授予访问权限、更新记录等
break;
}
case 'checkout.session.expired': {
const session = event.data.object as Stripe.Checkout.Session;
console.log('会话已过期:', session.id); // 可以选择通知客户或清理任何未处理的订单记录
break;
}
case 'checkout.session.async_payment_succeeded': {
const session = event.data.object as Stripe.Checkout.Session;
console.log('该会话的异步支付操作已成功完成:', session.id); // 由于支付已经完成,现在可以执行订单完成流程
break;
}
case 'checkout.session asynchronously_payment_failed': {
const session = event.data-object as Stripe.Checkout.Session;
console.log('该会话的异步支付操作失败:', session.id); // 需要通知客户支付失败的原因
break;
}
case 'customer.subscription_deleted': {
const subscription = event.data.object as Stripe.Subscription;
console.log('客户的订阅已被取消:', subscription.id); // 需要撤销该客户的访问权限
break;
}
default:
console.log('未识别的事件类型:', event.type);
}
res.send();
},
);
Webhook处理程序会检测五种关键的事件。
-
checkout.session_completed:当支付成功时会被触发——此时你可以完成订单处理、发送确认邮件或授予访问权限。 -
checkout.session.expired:当客户在完成支付之前会话失效时会被触发。 -
checkout.session.async_payment_succeeded:当使用延迟支付方式(如银行转账)的支付最终成功完成时会被触发。 -
checkout.session asynchronously_payment_failed:当使用延迟支付方式的支付失败时会被触发。 -
customer.subscriptiondeleted:当客户的订阅被取消时会被触发。
如何在Stripe控制台中配置Webhook
在能够接收Webhook事件之前,你需要告诉Stripe这些事件应该发送到哪里,以及你关注哪些具体的事件。请按照以下步骤操作:
-
进入Stripe控制台,然后选择“开发人员”>“Webhook”。
-
点击“添加目的地”。
-
在账户类型中,选择“已连接的V2账户”,因为你的支付是通过已连接的商家账户进行的。
-
在“需要监听的事件”选项下,选择“所有事件”,然后选中以下五种事件:
-
checkout.session.async_payment_succeeded:当使用延迟支付方式的支付最终成功完成时会被触发。 -
checkout.session_completed:当购物流程成功完成时会被触发。 -
checkout.session.expired:当购物流程在完成之前会话失效时会被触发。 -
checkout.session.async_payment_failed:当使用延迟支付方式的支付失败时会被触发。 -
customer.subscriptiondeleted:当客户的订阅被取消时会被触发。
-
-
输入你的Webhook端点URL。对于生产环境,这个地址应该是类似于https://yourdomain.com/api/webhook这样的格式。而对于本地开发环境,你应该使用Stripe CLI(具体方法将在后面介绍)。
-
点击“添加目的地”以保存设置。
如何在本地测试Webhook
在进行本地开发时,你不需要将服务器暴露到互联网上。只需安装Stripe CLI并运行以下命令:
brew install stripe/stripe-cli/stripe
stripe login
stripe listen --forward-to localhost:4242/webhook
CLI会生成一个以whsec_开头的Webhook签名密钥。请将这个密钥添加到你的.env文件中,设置为WEBHOOK_SECRET。CLI会拦截来自Stripe的所有Webhook事件,并将其转发到你的本地服务器上,这样你就可以在无需进行任何部署的情况下测试整个支付流程了。
如何添加计费门户
计费门户允许客户自行管理他们的订阅服务,而无需您为这一功能开发任何用户界面。Stripe负责处理所有的相关操作——客户可以更新支付方式、更换套餐或取消订阅。
// 创建计费门户会话
router.post(
"/create-portal-session",
async (req: Request, res: Response) => {
const { session_id } = req.body as {
session_id: string;
};
try {
const session =
await stripe.checkoutsessions.retrieve(
session_id
);
const portalSession =
await stripe.billingPortalSessions.create({
customer_account: session.customer_account as string,
return_url: `\({process.env.DOMAIN}?session_id=\){session_id}`,
});
res.redirect(303, portalSession.url);
} catch (error) {
const message =
error instanceof Error
? error.message
: "未知错误";
res.status(500).json({ error: message });
}
}
);
这条路由会使用之前结账过程中获得的session_id来检索对应的客户信息,并创建一个新的计费门户会话。customer_account字段用于将计费门户与正确的客户账户关联起来,这样客户看到的就只会是与该商家相关的订阅服务。
现在需要添加JSON解析器,并将路由配置添加到应用中。这些操作必须在webhook路由之后进行:
// JSON和URL编码解析器的配置(放在webhook路由之后)
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
// 将所有路由挂载到/api目录下
app.use('/api', router);
const PORT: number = parseInt(process.env.PORT || '4242', 10);
app.listen(PORT, () => {
console.log(`服务器运行在端口 ${PORT} 上`);
});
如何构建Next.js前端应用
进入客户端目录,使用TypeScript创建一个新的Next.js项目:
cd ../client
npx create-next-app@latest . --typescript --app --tailwind --eslint
npm install axios
如何创建账户上下文
您需要一种方法,以便在所有组件中共享商家的账户ID。请在contexts/AccountContext.tsx文件中创建一个上下文提供者:
'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
import { useSearchParams } from 'next/navigation';
interface AccountContextType {
accountId: string | null;
setAccountId: (id: string | null) => void;
}
const AccountContext = createContext<AccountContextType | undefined>>(undefined);
export function useAccount(): AccountContextType {
const context = useContext(AccountContext);
if (!context) {
throw new Error('useAccount必须在AccountProvider内部使用');
}
return context;
}
export function AccountProvider({ children }: { children: ReactNode }) {
const searchParams = useSearchParams();
const [accountId, setAccountId] = useState<string | null>>(searchParams.get('accountId');
return (
{children}
);
}
该上下文会存储当前商家的账户ID,并在整个应用程序中使其可用。在应用程序初次加载时,它会检查URL中是否包含accountId查询参数——这就是Stripe的引导流程如何将账户ID传回给您的应用程序的。
如何创建账户状态钩子
在hooks/useAccountStatus.ts文件中创建一个自定义钩子,用于定期查询账户状态:
如何构建商家引导组件
创建components/ConnectOnboarding.tsx文件:
创建您的卖家账户
setEmail(e.target.value)}
className="w-full border p-2 rounded mb-4"
/>

