你是否曾经好奇过,像Etsy、Uber或Teachable这样的平台是如何为成千上万的卖家处理支付事务的?答案就是多卖家市场平台:这种平台上,商家可以注册账号、列出自己提供的产品或服务,并直接从顾客那里收取款项。

在本手册中,你将使用TypeScript从零开始构建一个完整的市场平台。你不需要使用传统的数据库,而是会利用Stripe来管理产品目录和处理支付事务。

许多现实世界中的市场平台都是这样运作的:Stripe负责存储产品信息、价格以及顾客数据,而你的应用程序则负责提供用户友好的界面。

你将会构建以下内容:

  1. 商家注册流程,帮助卖家创建账户并连接Stripe系统

  2. 产品管理系统,让商家能够直接通过Stripe添加和列出产品

  3. 结账流程,支持一次性支付和定期订阅两种支付方式

  4. 实时监听支付事件的Webhook功能

  5. 顾客可以用来管理自己订阅服务的计费门户

  6. 供顾客浏览和购买产品的完整购物界面

你还可以从文末提供的GitHub仓库中获取完整的源代码。

目录

先决条件

在开始之前,请确保您具备以下条件:

  1. 您的机器上已安装Node.js(版本18或更高)。

  2. 您需要对React、TypeScript和REST API有基本的了解。

  3. 您需要一个Stripe账户(可以在stripe.com免费注册)。

  4. 您需要一个代码编辑器,例如VS Code。

对于这个项目来说,不需要数据库。Stripe会负责存储您的产品信息、价格以及客户资料。这样的设计使得系统架构更为简单,同时也符合许多实际运营中的市场平台的运作方式。

什么是Stripe Connect?

Stripe Connect是一组专为各种平台和市场设计的API。它允许您为商家创建账户(Stripe将这些账户称为“关联账户”),将付款请求路由到这些账户,并从每笔交易中收取平台费用。

在本教程中,您将使用Stripe的V2 Accounts API。这是创建关联账户的较新且推荐的方式。通过V2 API,您可以通过配置对象来指定每个账户可以执行哪些操作(例如接受信用卡付款、接收款项),而Stripe会负责处理所有的合规性检测和身份验证流程。

付款流程的具体步骤如下:

  1. 顾客在您的市场平台上选择商品并点击“结账”按钮。

  2. 您的服务器会创建一个与商家关联账户相关联的Stripe结算会话。

  3. 顾客会在Stripe提供的结算页面上进行支付。

  4. Stripe会自动分配付款金额:商家会收到属于他们的部分,而您的平台也会收取相应的服务费用。

  5. Stripe会向您的服务器发送一个Webhook通知,确认付款已经完成。

  6. 商家可以通过自己的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创建一个新的关联账户。以下是其中一些关键的配置选项:

  1. dashboard: "full"允许商家访问自己的Stripe管理面板,在那里他们可以查看交易记录、管理付款事宜以及处理纠纷。

  2. responsibilities用于指定谁负责收取费用,以及谁需要承担损失。将这两项都设置为“stripe”意味着由Stripe来处理这些事务,这是最简单的配置方式。

  3. identity用于设置国家代码和实体类型。请将“GB”替换为商家的实际国家代码(例如,美国的代码是“US”)。

  4. 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这些事件应该发送到哪里,以及你关注哪些具体的事件。请按照以下步骤操作:

  1. 进入Stripe控制台,然后选择“开发人员”>“Webhook”。

  2. 点击“添加目的地”。

  3. 在账户类型中,选择“已连接的V2账户”,因为你的支付是通过已连接的商家账户进行的。

  4. 在“需要监听的事件”选项下,选择“所有事件”,然后选中以下五种事件:

    • checkout.session.async_payment_succeeded:当使用延迟支付方式的支付最终成功完成时会被触发。

    • checkout.session_completed:当购物流程成功完成时会被触发。

    • checkout.session.expired:当购物流程在完成之前会话失效时会被触发。

    • checkout.session.async_payment_failed:当使用延迟支付方式的支付失败时会被触发。

    • customer.subscriptiondeleted:当客户的订阅被取消时会被触发。

  5. 输入你的Webhook端点URL。对于生产环境,这个地址应该是类似于https://yourdomain.com/api/webhook这样的格式。而对于本地开发环境,你应该使用Stripe CLI(具体方法将在后面介绍)。

  6. 点击“添加目的地”以保存设置。

如何在本地测试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文件:

该组件负责处理商家体验中的两种状态:如果用户没有账户,它会显示一个简单的电子邮件注册表单;一旦用户创建了账户,系统就会显示账户状态,并在需要时提供引导按钮。

如何构建产品页面、产品列表及结账功能

创建文件 `components/Products.tsx`:


'use client';
import { useState, useEffect } from 'react';
import { useAccount } from '@/contexts/AccountContext';
import useAccountStatus from '@/hooks/useAccountStatus';
const API_URL = 'http://localhost:4242/api';
interface Product {
  id: string;
  name: string;
  description: string | null;
  price: number | null;
  priceId: string;
  period: string | null;
}
export default function Products() {
  const { accountId } = useAccount();
  const { needsOnboarding } = useAccountStatus();
  const [products, setProducts] = useState:;
  useEffect(() => {
    if (!accountId || needsOnboarding) return;
    const fetchProducts = async () => {
      const res = await fetch(`\({API_URL}/products/\){accountId}`);
      const data: Product[] = await res.json();
      setProducts(data);
    };
    fetchProducts();
    const interval = setInterval(fetchProducts, 5000);
    return () => clearInterval(interval);
  }, [accountId, needsOnboarding]);
  return (
    
{''} {products.map((product) => (

{product.name}

{product.description}

${((product.price ?? 0) / 100).toFixed(2)} {product.period ? ` / ${product_period}` : ""}

))}
); }

`Products` 组件会从商家的 Stripe 账户中获取所有产品信息,并以响应式网格布局将它们显示出来。结账按钮会直接将表单数据发送到后端服务器,随后系统会将客户重定向到 Stripe 提供的结账页面。请注意,按钮上的文字会根据所购买的产品是一次性商品还是订阅服务而发生变化。

如何构建产品注册表单

商家需要一种从前端添加产品信息的方法。请创建文件 `components/ProductForm.tsx`:


'use client';
import { useState } from 'react';
import { useAccount } from '@/contexts/AccountContext';
import useAccountStatus from '@/hooks/useAccountStatus';
const API_URL = 'http://localhost:4242/api';
interface ProductFormData {
  productName: string;
  productDescription: string;
  productPrice: number;
}
export default function ProductForm() {
  const { accountId } = useAccount();
  const { needsOnboarding } = useAccountStatus();
  const [showForm, setShowForm] = useState:(false);
  const [formData, setFormData] = useState/({
    productName: '',
    productDescription: '',
    productPrice: 1000,
  });
  const handleSubmit = async (e: React.FormEvent): Promise => {
    e.preventDefault();
    if (!accountId || needsOnboarding) return;
    await fetch(`${API_URL}/create-product`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        ...formData,
        accountId,
      }),
    }); // 重置表单并隐藏它
    setFormData({
      productName: '',
      productDescription: '',
      productPrice: 1000,
    });
    setShowForm(false);
  }; // 只有在商家完成注册流程且能够接受付款时,才显示该表单
  if (!accountId || needsOnboarding) return null;
  return (
    
{showForm && (
{ setFormData({ ...formData, productName: e.target.value, }) } className="w-full border p-2 rounded" required />
{ setFormData({ ...formData, productDescription: e.target.value, }) } className="w-full border p-2 rounded" />
{ setFormData({ ...formData, productPrice: parseInt(e.target.value), }) } className="w-full border p-2 rounded" required />
)}
); }

该组件仅在商家完成注册流程后才会被渲染(在代码的开头有这样一段判断:`if (!accountId || needsOnboarding) return null`)。它会显示一个表格,让商家输入产品名称、描述以及价格(价格以分为单位)。当商家提交这些信息时,系统会调用你的`/api/create-product`接口,从而在商家关联的Stripe账户中创建相应的产品及其价格信息。

之所以价格字段要使用分作为单位,是因为Stripe就是要求这样输入的。因此,如果商家想出售价格为25.00美元的产品,他们就需要输入2500分。在实际的应用程序中,你可以添加更友好的输入界面,让商家可以直接输入25.00这样的数值,系统会自动将其转换为分。

如何构建主页面

最后,将所有这些组件整合到`app/page.tsx`文件中:

市场监控面板
        
        
        
      
    
  );
}

如何测试整个流程

首先启动两台服务器:

# 终端1 - 后端服务器
cd server
npm run dev

# 终端2 - 前端服务器
cd client
npm run dev

# 终端3 - Stripe webhook监听器
stripe listen --forward-to localhost:4242/api/webhook

现在开始测试整个流程:

  1. 访问http://localhost:3000,输入电子邮件地址来创建商家账户。

  2. 点击“完成注册流程”,并填写Stripe提供的测试注册表格。电话号码可以使用000-000-0000这样的测试数值,社会安全号的最后四位也可以填0000。

  3. 等待几秒钟,直到账户状态更新。一旦收费功能启用,你就可以添加产品了。

  4. 使用产品表格创建一个产品,将价格设置为分——例如,2500分代表25.00美元。

  5. 点击产品的“立即购买”按钮,开始结账流程。

  6. 在Stripe的结账页面上,使用测试信用卡号4242 4242 4242 4242进行支付,这个卡号的有效期和CVC码都可以随意填写。

  7. 检查终端界面,你应该会看到确认付款的webhook事件信息。

  8. 查看Stripe的市场监控面板,确认付款信息、应用费用以及资金是否已转入商家关联的账户。

支付分摊机制的工作原理

当顾客为某件产品支付25.00美元时,具体会发生以下这些步骤:

  1. 客户在Stripe的结账页面上支付25.00美元。

  2. Stripe会扣除其处理费用(对于美国银行卡,这笔费用约为2.9%加上0.30美元)。

  3. 您的平台会收取您设定的手续费(在我们的例子中为1.23美元)。

  4. 剩余金额会被转入商家的Stripe账户中。

  5. 商家可以通过Stripe的控制面板将资金提取到自己的银行账户中。

您可以在结账流程中设置手续费。在正式运行的市场中,通常会将这笔费用设定为交易总额的一定百分比。例如,如果决定收取10%的手续费,可以这样编写代码:

onst applicationFee = Math.round(
  (price.unit_amount ?? 0) * 0.1
);

后续步骤

现在,您已经搭建了一个可以正常运行的市场平台。在正式上线之前,还可以考虑进行以下优化:

  • 使用NextAuth.js添加认证功能,这样商家就可以在不同会话中安全地登录并管理自己的账户。

  • 利用Zod在请求数据到达Stripe之前对其进行验证。

  • 支持用户通过Cloudinary或AWS S3上传产品图片,并将图片链接添加到产品的元数据中。

  • 为商家和消费者分别设计不同的界面。目前,该应用将这两种功能合并在同一个页面上。

  • 将后端服务部署到Railway或Render平台上,前端服务则部署到Vercel平台上。同时,请更新Stripe控制面板中的Webhook地址,使其指向正式运行的服务器。

您可以在GitHub上找到本教程的完整源代码:https://github.com/michaelokolo/marketplace

致谢

本教程中的一些API使用方式借鉴了Stripe官方文档中的示例。这些示例经过修改,用于演示如何构建一个完整的多卖家市场平台架构。

总结

通过本教程,您成功搭建了一个完整的在线市场平台。商家可以通过Stripe Connect加入该平台,直接在Stripe系统中创建产品信息,并接收客户的付款——整个过程无需使用传统的数据库。

您还学会了如何利用Stripe的V2 Accounts API来实现商家的注册功能、在关联账户中创建产品及价格信息、设计既能处理一次性支付也能处理订阅服务的结账流程、通过Webhook监听付款事件,以及为消费者提供用于管理订阅信息的界面。

关键在于,Stripe Connect已经解决了运营市场平台过程中最复杂的问题——资金分割、税务合规、身份验证以及资金转账。您的任务就是在此基础上打造出色的用户体验。

如果觉得本教程对您有帮助,请将其分享给那些正在学习开发全栈应用的人吧。祝您编程愉快!

Comments are closed.