大多数关于Stripe的使用教程都会在结账页面结束。顾客点击“付款”按钮,Stripe会处理支付请求,然后教程就会恭喜你成功整合了支付功能。

但实际上,这仅仅是一个真正支付系统功能的10%而已。

顾客付款之后,你还需要将这笔交易记录到数据库中,发送确认邮件,并为顾客提供使用产品的权限(比如发送GitHub仓库邀请、提供API密钥或许可文件)。作为管理员,你还需要及时处理相关事务。两周后,当有人放弃结账流程时,你需要进行退款操作,并向这些顾客发送通知邮件。

这才是完整的支付生命周期,而大多数SaaS应用程序恰恰在这些环节出现了问题。

本文将指导你完成整个支付流程的构建,从“购买”按钮到“欢迎邮件”,以及其中涉及的每一个细节。所有的代码示例都来源于实际处理真实交易的应用程序。你会学到如何设计数据库结构、如何创建Stripe相关产品、如何构建结账流程、如何可靠地处理交易、如何进行退款操作、如何恢复被放弃的购物车信息,以及如何发送事务性邮件。

你将学习到以下内容:

  • 如何设计能够追踪交易每个环节的数据库结构

  • 如何通过编程方式创建Stripe相关产品及价格信息

  • 如何构建具备成功/取消处理功能的结账流程

  • 如何使用签名验证机制安全地处理Webhook请求

  • 如何将支付后的处理任务分解为多个可独立重试的步骤

  • 如何处理全额退款及部分退款操作,并自动撤销相关访问权限

  • 如何从被放弃的结账订单中挽回收入

  • 如何使用React Email和Resend工具构建事务性邮件模板

  • 如何使用Stripe CLI和Inngest在本地测试整个支付流程

目录

先决条件

要顺利跟随本教程进行学习,您需要熟悉以下内容:

  • TypeScript与Node.js

  • SQL数据库(示例中使用的是PostgreSQL)

  • React框架(用于处理电子邮件模板)

  • 对webhook的基本了解

您不需要具备这些特定库的预先使用经验。本手册会逐一解释这些内容。

需要安装的内容

请安装以下软件包才能运行代码示例:

bun add stripe drizzle-orm @neondatabase/serverless inngest resend @react-email/components

此外,您还需要:

环境变量设置

请在您的.env文件中配置以下环境变量:

# 数据库
DATABASE_URL=postgresql://...

# Stripe
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRO PRICE_ID=price_...

# 电子邮件
RESEND_API_KEY=re_...
EMAIL_FROM="您的应用 "
ADMIN_EMAIL=you@yourapp.com

# 应用程序
BETTER_AUTH_URL=http://localhost:3000

如何设计支付数据库架构

在编写任何与Stripe相关的代码之前,您需要先设计一个能够追踪购买流程中每个环节的数据库架构——包括从订单创建到完成、部分退款以及全额退款的整个过程。

购买状态转换图:通过Stripe webhook,订单状态会从“待处理”变为“已完成”,随后可能进入“已退款”或“部分退款”状态

当用户点击“购买”按钮时,订单状态会初始设置为待处理。一旦Stripe确认了付款信息,订单状态就会变为已完成。之后,它可能会进入已退款部分退款状态。那些从未完成支付的待处理订单会在24小时后自动失效(即被视为“弃置的购物车”)。

以下是我在实际生产环境中使用的数据库架构,它是使用Drizzle ORM工具设计的。本文中的示例代码需要访问一个私有的GitHub仓库,因为这个产品正是通过这种方式提供相关服务的。

您具体的操作步骤可能会有所不同:可能是为用户升级到Pro计划、提供API接口使用权限、解锁课程内容,或是激活订阅服务。虽然具体的字段设置和操作流程会有所变化,但整体的实现逻辑是相同的。

// src/lib/db/schema.ts
import {
  boolean,
  integer,
  pgEnum,
  pgTable,
  text,
  timestamp,
  varchar,
} from "drizzle-orm/pg-core";

export const purchaseTierEnum = pgEnum("purchase_tier", ["pro"));
export const purchaseStatusEnum = pgEnum("purchase_status", [
  "completed",
  "partially_refunded",
  "refunded",
]);

export const users = pgTable("users", {
  id: text("id").primaryKey(),
  email: varchar("email", { length: 255 }).notNull().unique(),
  emailVerified: boolean("email_verified").notNull().default(false),
  name: text("name"),
  image: text("image"),
  githubUsername: text("github_username"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const purchases = pgTable("purchases", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
  userId: text("user_id")
    .notNull()
    .references(() =&> users.id, { onDelete: "cascade" }),
  stripeCheckoutSessionId: text("stripe_checkout_session_id")
    .notNull()
    .unique(),
  stripeCustomerId: text("stripe_customer_id"),
  stripePaymentIntentId: text("stripe_payment(intent_id"),
  tier: purchaseTierEnum("tier").notNull(),
  status: purchaseStatusEnum("status").notNull().default("completed"),
  githubAccessGranted: boolean("github_access_granted")
    .notNull()
    .default(false),
  githubInvitationId: text("github_invitation_id"),
  amount: integer("amount").notNull(),
  currency: text("currency").notNull().default("usd"),
  purchasedAt: timestamp("purchased_at").notNull().defaultNow(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export type Purchase = typeof purchases.$inferSelect;
export type NewPurchase = typeof purchases.$inferInsert;

让我来解释一下设计这个数据结构时所做出的决策。

为什么需要三列Stripe标识符?

purchases表中存储了三列不同的Stripe标识符:stripeCheckoutSessionIdstripeCustomerIdstripePaymentIntentId

每一列都有不同的用途。

结算会话ID是顾客开始结账时首先获得的标识符。当顾客进入Stripe的结算页面后,Stripe会创建一个结算会话,并生成这个ID。顾客完成结算流程后,您可以使用这个ID来确认交易已完成。

对这一列设置unique()约束是为了确保数据的一致性。如果有人试图重复使用同一个会话ID进行结账操作,数据库会拒绝第二次插入请求。

客户ID是Stripe用于识别消费者的内部标识符。您需要这个ID才能在Stripe的仪表盘中查看客户的支付记录,或者为未来的结算操作预先填充他们的账单信息。

支付意图ID是Stripe在处理退款请求时使用的标识符。当charge.refunded事件发生时,Stripe会发送这个ID,但不会包含结算会话ID。如果没有存储这个字段,您就无法将退款操作与数据库中的相应交易记录关联起来。

为什么要在数据库中跟踪访问状态

githubAccessGrantedgithubInvitationId这些字段看起来可能没有必要。你可以查看GitHub的API来判断用户是否具有访问权限。但是,每次需要检查用户的访问状态时都去调用外部API,这种做法速度慢、会受到速率限制,而且也不可靠。

如果在你自己的数据库中跟踪访问状态,那么只需执行一次索引查询,就能得到“这个用户是否有访问权限”这一答案。同时,你也能知道该用户的访问权限是否曾经被授予过,这对于处理退款操作来说非常重要。如果githubAccessGranted的值为false,那么在办理退款时就不需要撤销任何权限设置。

为什么使用包含三个值的状态枚举类型

purchaseStatusEnum有三个取值:completedpartially_refundedrefunded

这一点对于后续的处理逻辑非常重要。你的仪表盘、分析工具、支持系统以及发送给客户的邮件内容,都需要知道购买的准确状态。部分获得退款的客户仍然具有访问权限,而全额退款的客户则没有这种权限。

如果你只将“退款”这一状态表示为布尔值,那么就无法区分部分退款和全额退款这两种情况。而这种区分对于是否需要撤销产品的访问权限至关重要。

如何生成并执行迁移操作

在定义好了数据库结构之后,就可以生成迁移文件并将其应用到数据库中:

# 根据数据库结构变化生成迁移SQL语句
bun run drizzle-kit generate

# 直接推送数据库结构变更(仅限开发环境)
bun run drizzle-kit push

# 在生产环境中执行迁移操作
bun run drizzle-kit migrate

Drizzle Kit会将你的TypeScript定义的数据库结构与实际数据库进行比较,并生成所需的SQL语句来使两者保持一致。在在生产环境中执行迁移之前,请务必先查看生成的迁移文件。因为数据库结构的变更往往是不可轻易撤销的操作。

在开发环境下,使用drizzle-kit push会更快,因为它可以直接应用变更而无需生成迁移文件。而在生产环境中,一定要先使用drizzle-kit generate生成迁移文件,然后再执行drizzle-kit migrate,这样就能为每一次数据库结构变更留下版本记录。

如何创建Stripe产品及价格信息

你可以通过Stripe的仪表盘来创建产品及价格信息,但通过编程方式来进行管理会更加方便,也更容易保证操作的一致性。下面是一个示例脚本,它可以帮助你完成所有必要的设置:

// src/lib/payments/seed.ts
import { stripe } from "./index";

const PRODUCTS = [
  {
    name: "我的SaaS产品",
    description: "全额访问权限,一次性购买",
    features: [
      "可以查看全部源代码",
      "具备适合生产环境的技术基础设施",
      "终身可享受更新服务",
    ],
    metadata: { tier: "pro" },
    prices: [
      {
        lookupKey: "pro_one_time",
        unitAmount: 19900, // 相当于199.00美元
        currency: "usd",
        nickname: "Pro One-Time",
      },
    ],
  },
];

async function main() {
  console.log("正在初始化Stripe的产品及价格信息...\n");

  for (const config of PRODUCTS) {
    // 创建或查找产品
    const products = await stripe.products.list({ active: true, limit: 100 });
    let product = products.data.find((p) => p.name === config.name);

    if (!product) {
      product = await stripeproducts.create({
        name: config.name,
        description: config.description,
        marketing_features: config.features.map((f) => ({ name: f }),
        metadata: config.metadata,
      });
      console.log(`已创建产品 "\({config.name}" (\){product.id})`);
    }

    // 创建价格信息
    for (const priceConfig of config.prices) {
      const existing = await stripe.prices.list({
        lookup_keys: [priceConfiglookupKey],
        active: true,
        limit: 1,
      });

      if (existing.data[0]) {
        console.log(`价格 "${priceConfig.lookupKey}"已经存在`);
        continue;
      }

      const price = await stripe.prices.create({
        product: product.id,
        unit_amount: priceConfig.unitAmount,
        currency: priceConfig(currency,
        nickname: priceConfig.nickname,
        lookup_key: priceConfiglookupKey,
        transfer_lookup_key: true,
      });

      console.log(`已创建价格 "\({priceConfigLookupKey}" (\){price.id})`);
    }
  }

  console.log("\n操作完成!请将价格ID添加到.env文件中,键名为STRIPE_PRO_PRICE_ID");
}

main().catch(console.error);

请使用bun run src/lib/payments/seed.ts来运行此脚本。

有几点需要注意。

  • 建议使用lookup_key,而非直接硬编码价格ID:测试模式与生产环境中的价格ID是不同的。使用 lookup key可以通过名称(如pro_one_time)来引用价格,而不是使用 Stripe 生成的 ID(如price_1P...)。

    transfer_lookup_key: true这个选项可以确保:如果你创建了一个具有相同 lookup key的新价格,系统会自动替换原有的价格。

  • 价格以分为单位:Stripe 的 API 要求输入的金额必须使用最小的货币单位。对于美元来说,19900表示 199.00 美元。

    这是一个常见的错误来源。请务必在数据库中将金额存储为分,只有在显示时才将其转换为美元。

  • 这个初始化脚本是幂等的:你可以多次安全地运行它。在创建新产品和新价格之前,该脚本会先检查是否存在相应的现有记录。

如何设置 Stripe 客户端

Stripe 客户端采用了延迟初始化的设计,因此在模块加载时如果 API 密钥未设置,导入该客户端也不会引发错误。在构建环境中,当环境变量未被设置时,这一点尤为重要。

// src/lib/payments/index.ts
import Stripe from "stripe";

let stripeClient: Stripe | null = null;

function getStripe(): Stripe {
  if (!stripeClient) {
    const secretKey = process.env.STRIPE_SECRET_KEY;
    if (!secretKey) {
      throw new Error("STRIPE_SECRET_KEY 未设置");
    }
    stripeClient = new Stripe(secretKey);
  }
  return stripeClient;
}

export const stripe = new Proxy({} as Stripe, {
  get(_, prop) {
    return Reflect.get(getStripe(), prop);
  },
});

这里的关键在于使用了Proxy包装器。应用程序中的所有代码都会导入stripe,并调用诸如stripe.checkoutSessions.create(...)这样的方法。代理会拦截所有的属性访问请求,并将它们转发给延迟初始化的 Stripe 客户端。

这意味着 Stripe SDK 只有在实际被使用时才会被初始化,而不会在模块被导入时就被初始化。

如何构建结账流程

结账流程包含三个部分:创建会话、重定向客户页面以及处理退货操作。

如何创建结账会话

以下是用于创建一次性支付所需的 Stripe 结账会话的函数:

// src/lib/payments/index.ts
export async function createOneTimeCheckoutSession(params: {
  priceId: string;
  successUrl: string;
  cancelUrl: string;
  metadata: Record;
  customerEmail?: string;
  couponId?: string;
}) {
  const client = getStripe();

  const session = await client.checkoutSessions.create({
    mode: "payment",
    line_items: [{ price: params.priceId, quantity: 1 }],
    success_url: params.successUrl,
    cancel_url: params.cancelUrl,
    metadata: params.metadata,
    ...(params.customerEmail && {
      customer_email: params.customerEmail,
    }),
    ...(params.couponId
      ? { discounts: [{ coupon: params.couponId }] }
      : { allow_promotion_codes: true }),
  });

  return session;
}

这里有三个细节需要注意。

  • mode: "payment"这一设置告诉Stripe这是一次性支付,而非订阅服务。对于订阅业务,应使用mode: "subscription"。这个设置会影响Stripe在支付完成后发送哪些Webhook事件。

  • metadata字段用于将Stripe会话信息与您的应用程序关联起来。您可以通过该字段传递内部产品等级、用户ID或支付完成后所需的其他数据。Stripe会保存这些元数据,并将其包含在Webhook事件和API响应中。

  • allow_promotion_codes: true这一选项会在结账页面显示促销码输入框。如果您有特定的优惠券可以使用(例如通过URL参数传递),则应通过discounts字段来传递该优惠信息。这两种方式不能同时使用。

如何创建结账API端点

以下是用于创建结账会话并返回相应URL的API端点代码:

// src/server/api.ts
app.post("/api/payments/checkout", async ({ set }) => {
  const priceId = process.env.STRIPE_PRO PRICE_ID;

  if (!priceId) {
    set.status = 500;
    return { error: "价格信息未配置" };
  }

  const baseUrl = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
  const tier = "pro";

  const checkoutSession = await createOneTimeCheckoutSession({
    priceId,
    successUrl: `${baseUrl}/dashboard?purchase=success&session_id={CHECKOUT_SESSION_ID}`,
    cancelUrl: `${baseUrl}/pricing`,
    metadata: { tier },
  });

  return { url: checkoutSession.url };
});

在成功URL中的{CHECKOUTSESSION_ID}是一个模板变量,Stripe会在重定向客户时将其替换为实际的会话ID。这样,您的前端应用程序就能知道是哪个会话完成了支付操作。

结账后如何确认购买结果

当客户访问成功URL时,您的前端应用程序会从URL中提取session_id,并将其发送到“确认购买”端点。该端点会验证支付信息并创建购买记录。

// src/server/api.ts
app.post(
  "/api/purchases/claim",
  async ({ body, request, set }) => {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session) {
      set.status = 401;
      return { error: "未经授权" };
    }

    const { sessionId } = body;

    // 检查该会话是否已被确认
    const existing = await db
      .select()
      .from(purchases)
      .where(eq(purchases.stripeCheckoutSessionId, sessionId))
      .limit(1);

    if (existing[0]) {
      return { success: true, alreadyClaimed: true, tier: existing[0].tier };
    }

    // 获取Stripe结账会话信息以验证支付
    const stripeSession = await retrieveCheckoutSession(sessionId);

    if (stripeSession.payment_status !== "paid") {
      set.status = 400;
      return { error: "支付未完成" };
    }

    const tier = (stripeSession.metadata?.tier ?? "pro") as PaymentTier;

    // 创建购买记录
    await db.insert(purchases).values({
      userId: session.user.id,
      stripeCheckoutSessionId: sessionId,
      stripeCustomerId:
        typeof stripeSession.customer === "string"
          ? stripeSession.customer
          : stripeSession.customer?.id ?? null,
      stripePaymentIntentId:
        typeof stripeSession.payment_intent === "string"
          ? stripeSession.payment(intent
          : stripeSession.paymentintent?.id ?? null,
      tier,
      status: "completed",
      amount: stripeSession.amount_total ?? 0,
      currency: stripeSession(currency ?? "usd",
    });

    // 触发后台处理流程
    await inngest.send({
      name: "purchase/completed",
      data: {
        userId: session.user.id,
        tier,
        sessionId,
      },
    });

    return { success: true, tier };
  },
  {
    body: t.Object({
      sessionId: t.String(),
    }),
  }
);

这个接口端点会按顺序执行四项操作。

  1. 首先,它会检查该会话是否已被他人占用。 数据结构中针对`stripeCheckoutSessionId`设置的`unique()`约束可以防止记录重复,但先进行这一检查能确保在不会引发数据库错误的情况下返回正确的响应结果。

  2. 其次,它会通过Stripe来验证支付信息。永远不要相信客户端提供的数据。虽然前端会传递会话ID,但你仍需要调用Stripe的API来确认`payment_status`是否为“paid”。

  3. 第三,它會创建购买记录。请注意,它会从Stripe会话中提取`customer`和`payment_intent`这两个字段。根据你的Stripe API配置,这些字段可能会以字符串或对象的形式返回,因此代码中使用了条件语句来处理这两种情况。

  4. 第四,它会向Inngest发送一个purchase/completed事件。这一操作会触发后台处理流程,从而处理邮件通知、权限设置、数据分析以及后续任务安排等工作。不过,这个API接口本身并不负责执行这些后端操作,而是会立即返回`{ success: true }`。

将购买记录的创建与后处理过程分开处理是至关重要的。数据库插入操作速度很快且可靠性很高,而后续的处理流程(如发送邮件、调用API、进行数据分析)则通常速度较慢且存在一定的不确定性。

通过这种分离机制,你可以确保客户能够立即收到表示交易成功的响应,同时后台任务也能稳定地继续执行。

如何安全地处理Webhook

你的Webhook接口端点是接收Stripe在结账流程之外发生的各种事件的入口点,例如退款、会话过期或争议处理等事件。

如何验证Webhook签名

Stripe发送的每个Webhook请求都会包含一个签名字段。在处理这些事件之前,你必须先验证这个签名。如果不进行验证,任何人都可以向你的Webhook地址发送伪造的事件信息。


// src/lib/payments/index.ts
export async function constructWebhookEvent(
  payload: string | Buffer,
  signature: string
) {
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
  if (!webhookSecret) {
    throw new Error("STRIPE/WebHOOK_SECRET未设置");
  }
  const client = getStripe();
  return client.webhooksconstructEventAsync(payload, signature, webhookSecret);
}

有一个非常重要的细节需要注意:请使用constructEventAsync方法,而不是constructEvent方法。异步版本使用了Web Crypto API,这种API与Bun和Cloudflare Workers等现代运行环境兼容;而同步版本则依赖于Node.js的`crypto`模块,但并非所有环境都支持这个模块。

另一个关键点也是:在验证签名时,必须使用原始的请求体。如果你的开发框架在访问请求数据之前先将其解析为JSON格式,那么签名验证就会失败。因为签名计算是基于请求数据的原始字节进行的,而不是解析后的JSON格式。

如何构建Webhook端点

以下是用于处理生产环境Webhook的代码。它的唯一作用就是验证接收到的事件信息,并将其转发到后台作业系统。

// src/server/api.ts
app.post("/api/payments/webhook", async ({ request, set }) => {
  const body = await request.text();
  const sig = request.headers.get("stripe-signature");

  if (!sig) {
    set.status = 400;
    return { error: "签名缺失" };
  }

  try {
    const event = await constructWebhookEvent(body, sig);
    console.log(`[Webhook] 收到类型为 ${event.type} 的事件`);
    
    if (event.type === "charge.refunded") {
      const charge = event.data.object as {
        id: string,
        paymentIntent: string,
        amount: number,
        amount_refunded: number,
        currency: string,
      };
      await inngest.send({
        name: "stripe/charge/refunded",
        data: {
          chargeId: charge.id,
          paymentIntentId: charge.payment(intent,
          amountRefunded: charge.amount_refunded,
          originalAmount: charge.amount,
          currency: charge.currency,
        },
      });
    }

    if (event.type === "checkout.session.expired") {
      const session = event.data.object as {
        id: string,
        customer_email: string | null,
      };
      await inngest.send({
        name: "stripe/checkout.session.expired",
        data: {
          sessionId: session.id,
          customerEmail: session.customer_email,
        },
      });
    }

    return { received: true };
  } catch (error) {
    console.error("[Webhook] Stripe验证失败:", error);
    set.status = 400;
    return { error: "Webhook验证失败" };
  }
});

这种处理方式属于“轻量级Webhook处理器”模式。请注意,它不会执行任何数据库查询、发送电子邮件、授予访问权限或调用外部服务。它只会验证签名、提取所需的字段信息,然后将这些数据发送给Inngest系统。

整个处理流程在几毫秒内就能完成。

为什么这一点很重要呢?Stripe要求你的Webhook在大约20秒内返回2xx状态码的响应。如果你的处理器需要执行过多的操作(如数据库查询、邮件发送或API调用),就很容易导致超时。

在这种情况下,Stripe会将请求标记为失败,并重新尝试处理该事件。这样一来,就会导致部分处理被重复进行,从而增加系统负担。

而“轻量级处理器”则完全避免了这类问题。它只负责验证数据、将其放入队列中,然后返回结果。所有复杂的处理工作都是通过异步方式在后台持久化函数中完成的。

为什么要在入队之前提取字段?

你可能会注意到,Webhook处理器在将数据发送给Inngest之前,会先从接收到的事件信息中提取特定的字段:

await inngest.send({
  name: "stripe/charge/refunded",
  data: {
    chargeId: charge.id,
    paymentIntentId: charge.payment(intent,
    amount_refunded: charge.amount_refunded,
    originalAmount: charge.amount,
    currency: charge(currency,
  },
});

为什么不直接发送整个Stripe事件对象呢?原因有两条。

首先,Stripe事件对象体积较大且结构复杂、层次繁多。而你的后台函数实际上只需要五个字段而已。如果发送整个对象,那么在每次执行过程中,持久化函数都需要存储大量数据,经过数千次运行后,这个数据量会逐渐累积起来。

其次,在Webhook处理程序和后台函数之间,明确指定需要提取哪些字段,能够确保两者之间的接口更加清晰、易于维护。如果Stripe在未来版本的API中修改了事件对象的结构,你只需要更新Webhook处理程序中的数据提取逻辑即可。因为后台函数依赖的是你自己定义的数据结构,而不是Stripe提供的结构,所以它们依然可以正常工作。

如何在生产环境中配置Webhook

在 production 环境中,你可以通过 Stripe 控制台来配置 Webhook:

  1. 进入 Stripe 控制台,然后选择“开发人员”选项卡,再进入“Webhook”设置。

  2. 添加一个指向你的生产环境 URL 的端点:`https://yourapp.com/api/payments/webhook`。

  3. 选择你希望接收的事件类型:`charge.refunded` 和 `checkout.session.expired`。

  4. 复制签名密钥,并将其作为 `STRIPE_WEBHOOK_SECRET` 变量添加到你的生产环境配置中。

生产环境的签名密钥与Stripe CLI为本地测试生成的密钥是不同的。请确保你在不同环境中正确设置了相应的环境变量。

应该关注哪些Webhook事件

为了实现完整的支付流程,你需要在 Stripe 中配置以下这些 Webhook 事件:

>

事件类型 触发条件 应对措施
charge.refunded 客户收到退款 取消访问权限(全额退款)或更新状态(部分退款)
checkout.session.expired 购物会话超时(24小时后) 发送弃用订单恢复邮件

对于基于订阅的计费模式,你还需要关注 `customer.subscription.updated`、`customersubscriptiondeleted` 和 `invoice.payment_failed` 这些事件。不过本文主要介绍一次性支付场景,因此示例中只涉及前两种事件。

值得注意的是,《checkout.session_completed》这个事件并没有被包含在上述列表中。对于一次性支付来说,你通常会在“claim”接口处处理购买流程,而不是通过 Webhook 来完成这一操作,因为这时你需要使用经过身份验证的用户会话信息,才能将购买记录与用户的账户关联起来。

如何利用持久化后台作业来处理购买请求

这正是支付流程的核心所在。在购买记录创建完毕且 `purchase/completed` 事件被发送之后,持久化函数会接管后续的所有支付后处理工作。

这个函数中的每一步都会被单独标记为检查点。如果第5步失败,那么第1步到第4步就不会重新执行。第5步会自动重试,一旦成功,第6步到第9步才会继续执行。

这就是“持久化执行”的含义。这也是开发环境和生产环境中的支付系统之所以存在差异的原因所在。

我使用Inngest来实现这一功能。它是一个基于事件驱动的持久化执行平台,能够直接提供逐步检查点的功能。你可以通过step.run()块来定义函数,而Inngest会负责处理重试逻辑、状态保存以及可观测性相关的问题。

使用Inngest的客户端配置非常简单:


// src/lib/jobs/client.ts
import { Inngest } from "inngest";

export const inngest = new Inngest({
  id: "my-app",
});

你需要将你的函数注册到Inngest的服务处理程序中,这样开发服务器(以及生产环境)才能识别这些函数:


import { serve } from "inngest/bun";
import { inngest } from "@/lib/jobs/client";
import { stripeFunctions } from"user/lib/jobs/functions/stripe";

const inngestHandler = serve({
  client: inngest,
  functions: [...stripeFunctions],
});

// 将处理程序挂载到你的API上
app.all("/api/inngest", async (ctx) => {
  return inngestHandler(ctx.request);
});

以下是完整的购买功能实现代码:


// src/lib/jobs/functions/stripe.ts
import { eq } from "drizzle-orm";
import { createElement } from "react";

import { inngest } from "../client";
import { trackServerEvent } from "@/lib/analytics/server";
import { brand } from"flib/brand";
import { db, purchases, users } from"flib/db";
import {
  sendEmail,
  PurchaseConfirmationEmail,
  AdminPurchaseNotificationEmail,
  RepoAccessGrantedEmail,
} from"flib/email";
import { addCollaborator } from"flib/github";

export const handlePurchaseCompleted = inngest.createFunction(
  { id: "purchase-completed", triggers: [{ event: "purchase/completed" }] },
  async ({ event, step }) => {
    const { userId, tier, sessionId } = event.data as {
      userId: string;
      tier: string;
      sessionId: string;
    };

    // 第1步:查询用户信息和购买详情
    const { user, purchase } = await step.run(
      "lookup-user-and-purchase",
      async () => {
        const userResult = await db
          .select({
            id: users.id,
            email: users.email,
            name: users.name,
            githubUsername: users.githubUsername,
          })
          .from(users)
          .where(eq(users.id, userId))
          .limit(1);

        const foundUser = userResult[0];
        if (!foundUser) {
          throw new Error(`未找到用户:${userId}`);
        }

        const purchaseResult = await db
          .select({
            amount: purchases.amount,
            currency: purchases(currency,
            stripePaymentIntentId: purchases.stripePaymentIntentId,
          })
          .from(purchases)
          .where(eq(purchases.stripeCheckoutSessionId, sessionId))
          .limit(1);

        const foundPurchase = purchaseResult[0];

        return {
          user: foundUser,
          purchase: foundPurchase ?? {
            amount: 0,
            currency: "usd",
            stripePaymentIntentId: null,
          },
        };
      }
    );

    // 第2步:在分析系统中记录购买信息
    await step.run("track-purchase-to-posthog", async () => {
      try {
        await trackServerEvent(userId, "purchase_completed_server", {
          tier,
          amount_cents: purchase.amount,
          currency: purchase(currency,
          stripe_session_id: sessionId,
          stripe_payment_intent_id: purchase.stripePaymentIntentId,
        });
      } catch (error) {
        console.error(`无法将购买信息记录到PostHog系统中:`, error);
      }
    });

    // 第3步:向客户发送购买确认邮件
    await step.run("send-purchase-confirmation", async () => {
      await sendEmail({
        to: user.email,
        subject: `您在${brand.name}平台的购买已确认!`,
        template: createElement(PurchaseConfirmationEmail, {
          amount: purchase.amount,
          currency: purchase(currency,
          customerEmail: user.email,
        }),
      });
    });

    // 第4步:向管理员发送通知邮件
    await step.run("send-admin-notification", async () => {
      const adminEmail = process.env ADMIN_EMAIL;
      if (!adminEmail) return;

      await sendEmail({
        to: adminEmail,
        subject: `有新的促销活动:${user.email}`,
        template: createElement(AdminPurchaseNotificationEmail, {
          amount: purchase.amount,
          currency: purchase(currency,
          customerEmail: user.email,
          customerName: user.name,
          stripeSessionId: purchase.stripePaymentIntentId ?? sessionId,
        }),
      });
    });

    // 如果用户没有GitHub用户名,则直接结束执行
    if (!user.githubUsername) {
      return { success: true, userId, tier, githubAccessGranted: false };
    }

    // 第5步:授予用户访问GitHub仓库的权限
    const collaboratorResult = await step.run(
      "add-github-collaborator",
      async () => {
        return addCollaborator(user.githubUsername!);
      }
    );

    // 第6步:在分析系统中记录用户获得GitHub访问权限的情况
    await step.run("track-github-access", async () => {
      await trackServerEvent(userId, "github_access_granted", {
        tier,
        github_username: user.githubUsername,
        invitation_status: collaboratorResult.status,
      });
    });

    // 第7步:更新购买记录
    await step.run("update-purchase-record", async () => {
      await db
        .update(purchases)
        .set({
          githubAccessGranted: true,
          githubInvitationId: collaboratorResult.status,
         .updated: new Date(),
        })
        .where(eq(purchases.stripeCheckoutSessionId, sessionId));
    });

    // 第8步:向用户发送关于GitHub仓库访问权限的邮件
    await step.run("send-repo-access-email", async () => {
      const repoUrl = brand.social.github;
      await sendEmail({
        to: user.email,
        subject: `您在${brand.name}平台的GitHub仓库访问权限已准备就绪!`,
        template: createElement(RepoAccessGrantedEmail, { repoUrl }),
      });
    });

    // 第9步:安排后续跟进邮件
    await step.run("schedule-follow-up", async () => {
      const purchaseRecord = await db
        .select({ id: purchases.id })
        .from(purchases)
        .where(eq(purchases.stripeCheckoutSessionId, sessionId))
        .limit(1);

      if (purchaseRecord[0]) {
        await inngest.send({
          name: "purchase/follow-up.scheduled",
          data: {
            userId,
            purchaseId: purchaseRecord[0].id,
            tier,
          },
        });
      }
    });

    return { success: true, userId, tier, githubAccessGranted: true };
  }
);

这段代码确实很长。让我来解释一下为什么每个步骤都是独立的,以及它们为何必须分开执行。

步骤1:查询用户信息并完成购买操作

const { user, purchase } = await step.run(
  "lookup-user-and-purchase",
  async () => {
    // 向数据库查询用户信息及购买记录
    return { user: foundUser, purchase: foundPurchase };
  }
);

这个步骤会从数据库中检索用户的详细信息以及购买记录。后续的所有步骤都需要依赖这些数据(例如用户的电子邮件地址、购买金额以及用户的GitHub用户名)。

由于这个操作被封装在step.run()函数中,其返回值会被Inngest系统缓存起来。如果后面的某个步骤失败并且函数重新尝试执行,这个步骤就不会再次被运行,而是会直接使用之前缓存的数值。

如果数据库中不存在该用户的信息,这个步骤就会抛出错误,从而导致整个函数的执行中断。如果无法找到用户,继续执行后续步骤也是没有意义的。

步骤2:跟踪分析数据

await step.run("track-purchase-to-posthog", async () => {
  try {
    await trackServerEvent(userId, "purchase_completed_server", {
      tier,
      amount_cents: purchase.amount,
      currency: purchase.currency,
    });
  } catch (error) {
    console.error(`无法将购买信息发送到PostHog:`, error);
  }
});

分析数据的跟踪被单独列为一个步骤,是因为分析服务本身也可能出现故障。例如,PostHog服务器可能会设置访问速率限制,或者暂时无法正常访问。在这种情况下,我们不希望这些故障影响到确认邮件的发送。

注意这里的try-catch语句。当跟踪操作失败时,系统会记录错误信息,但不会导致整个函数停止执行。分析数据虽然很重要,但对于购买流程来说并不是必不可少的。

步骤3和步骤4:发送电子邮件通知

向客户发送确认邮件以及向管理员发送通知是两个独立的操作,因此它们也被分别列为不同的步骤。即使在向管理员发送通知时出现了错误(例如返回500状态码),客户仍然应该能够收到确认邮件。

// 步骤3:向客户发送确认邮件
await step.run("send-purchase-confirmation", async () => {
  await sendEmail({
    to: user.email,
    subject: `您在${brand.name}的购买已确认!`,
    template: createElement(PurchaseConfirmationEmail, {
      amount: purchase.amount,
      currency: purchase.currency,
      customerEmail: user.email,
    }),
  });
});

// 步骤4:向管理员发送通知
await step.run("send-admin-notification", async () => {
  const adminEmail = process.env ADMIN_EMAIL;
  if (!adminEmail) return;

  await sendEmail({
    to: adminEmail,
    subject: `有新优惠活动:${user.email}`,
    template: createElement(AdminPurchaseNotificationEmail, {
      // ... 仅针对管理员的字段
    }),
  });
});

在发送管理员通知的步骤中,我们添加了一个判断条件:如果ADMIN_EMAIL环境变量没有被设置,那么这个步骤就会立即结束执行。这样,在开发环境中,即使没有配置所有的环境变量,这个函数也能正常运行。

步骤5:授予产品访问权限

if (!user.githubUsername) {
  return { success: true, userId, tier, githubAccessGranted: false };
}

const collaboratorResult = await step.run(
  "add-github-collaborator",
  async () => {
    return addCollaborator(user.githubUsername!);
  }
);

这一步很可能会失败。GitHub的API存在速率限制,也可能会超时,而且用户的GitHub用户名也可能无效。

由于将这一操作单独列为一个步骤,因此如果GitHub API调用失败,也不会重新触发确认邮件发送流程(步骤3)或管理员通知流程(步骤4),因为这些步骤已经完成过了。

请注意,在步骤5之前函数就会提前返回结果。如果用户没有关联GitHub用户名,函数会在步骤4之后立即返回。只有当用户确实拥有GitHub账户时,后续步骤才会被执行。

步骤6-7:跟踪与更新信息

在授予用户GitHub访问权限后,该函数会会在分析系统中记录这一操作(步骤6),同时还会更新数据库中的购买记录(步骤7)。

数据库更新的顺序是经过刻意安排的——只有当邀请成功之后,才会将`githubAccessGranted: true`这个字段设置到位。如果先更新了数据库记录,而后续的GitHub请求失败了,那么数据库中就会显示访问权限已经被授予,但实际上并没有。

步骤8:发送访问权限确认邮件

await step.run("send-repo-access-email", async () => {
  const repoUrl = brand.social.github;
  await sendEmail({
    to: user.email,
    subject: `您的${brand.name}仓库访问权限已准备就绪!`,
    template: createElement(RepoAccessGrantedEmail, { repoUrl }),
  });
});

这封邮件只会在GitHub邀请被确认之后才会发送。这样的安排是很有道理的——如果在邀请尚未发送之前就告知客户“访问权限已准备就绪”,那是不合理的。

步骤9:安排后续操作流程

await step.run("schedule-follow-up", async () => {
  const purchaseRecord = await db
    .select({ id: purchases.id })
    .from(purchases)
    .where(eq(purchases.stripeCheckoutSessionId, sessionId))
    .limit(1);

  if (purchaseRecord[0]) {
    await inngest.send({
      name: "purchase/follow-up.scheduled",
      data: {
        userId,
        purchaseId: purchaseRecord[0].id,
        tier,
      },
    });
  }
});

最后一步会触发另一个函数,该函数负责发送后续邮件:在第七天发送入门指南,在第十四天请求用户提供反馈,在第三十天请求用户撰写使用体验评价。这是一个基于事件驱动的流程——一个函数完成执行后,会触发下一个函数的执行。

后续操作函数会使用`step.sleep()`来控制邮件发送之间的间隔时间,这样就不会浪费计算资源。

export const handlePurchaseFollowUp = inngest.createFunction(
  {
    id: "purchase-follow-up",
    triggers: [{ event: "purchase/follow-up.scheduled" }],
    cancelOn: [
      {
        event: "purchase/follow-up.cancelled",
        match: "data.purchaseId",
      },
    ],
  },
  async ({ event, step }) => {
    await step.sleep("wait-7-days", "7天");
    await step.run("send-day-7-email", async () => {
      // 发送入职指导信息
    });

    await step.sleep("wait-14-days", "7天");
    await step.run("send-day-14-email", async () => {
      // 发送反馈请求
    });
  }
);

cancelOn选项值得注意。如果购买了的商品被退款,那么你应该发送一个purchase/follow-up.cancelled事件,这样整个后续处理流程就会停止。这样一来,那些已经收到退款通知的客户就不会再收到多余的邮件了。

步骤分离规则

任何需要调用外部服务或可能会独立出现故障的操作,都应该被视为一个独立的步骤。数据库查询也是一个步骤,因为数据库有时可能会暂时无法访问;发送电子邮件或进行API调用同样属于步骤范畴,因为这些操作也有可能遇到错误或达到请求速率限制。

如果两个操作总是同时成功或同时失败,那么它们可以共享同一个步骤。但如果有疑问的话,最好还是将它们分开处理。这样做带来的开销微乎其微,而可靠性却会显著提高。

如何处理退款

退款处理是支付系统中最常被忽视的部分。你需要处理两种情况:全额退款(取消用户的访问权限)和部分退款(保留用户的访问权限并更新状态)。

以下是完整的退款处理流程:

// src/lib/jobs/functions/stripe.ts
export const handleRefund = inngest.createFunction(
  { id: "refund-processed", triggers: [{ event: "stripe/charge.refunded" }] },
  async ({ event, step }) => {
    const data = event.data as {
      chargeId: string;
      paymentIntentId: string;
      amountRefunded: number;
      originalAmount: number;
      currency: string;
    };

    const chargeId = data.chargeId;
    const paymentIntentId = data.paymentIntentId;
    const currency = data(currency;
    const amountRefunded = data.amountRefunded;
    const originalAmount = data.originalAmount;
    const isFullRefund = amountRefunded >= originalAmount;

    // 第一步:查找相应的购买记录和用户信息
    const { user, purchase } = await step.run(
      "lookup-purchase-by-payment-intent",
      async () => {
        const purchaseResult = await db
          .select({
            id: purchases.id,
            userId: purchases.userId,
            stripePaymentIntentId: purchases.stripePaymentIntentId,
            githubAccessGranted: purchases.githubAccessGranted,
          })
          .from(purchases)
          .where(eq(purchases.stripePaymentIntentId, paymentIntentId))
          .limit(1);

        const foundPurchase = purchaseResult[0];
        if (!foundPurchase) {
          return { user: null, purchase: null };
        }

        const userResult = await db
          .select({
            id: users.id,
            email: users.email,
            name: users.name,
            githubUsername: users.githubUsername,
          })
          .from(users)
          .where(eq(users.id, foundPurchase.userId))
          .limit(1);

        return { user: userResult[0] ?? null, purchase: foundPurchase };
      }
    );

    if (!purchase || !user) {
      return { success: false, reason: "没有找到对应的购买记录或用户信息" };
    }

    let accessRevoked = false;

    // 第二步:取消用户的GitHub访问权限(仅适用于全额退款情况)
    if (isFullRefund && user.githubUsername && purchase.githubAccessGranted) {
      const revokeResult = await step.run(
        "revoke-github-access",
        async () => {
          return removeCollaborator(user.githubUsername!);
        }
      );
      accessRevoked = revokeResult.success;
    }

    // 第三步:更新购买记录的状态
    await step.run("update-purchase-status", async () => {
      if (isFullRefund) {
        await db
          .update(purchases)
          .set({
            status: "refunded",
            githubAccessGranted: false,
           .updated: new Date(),
          })
          .where(eq(purchases.id, purchase.id));
      } else {
        await db
          .update(purchases)
          .set({
            status: "partially_refunded",
            updated: new Date(),
          })
          .where(eq(purchases.id, purchase.id));
      }
    });

    // 第四步:在分析系统中记录退款信息
    await step.run("track-refund-event", async () => {
      try {
        await trackServerEvent(user.id, "refundprocessed", {
          charge_id: chargeId,
          paymentIntent_id: paymentIntentId,
          amount_cents: amountRefunded,
          original_amount_cents: originalAmount,
          currency,
          is_full_refund: isFullRefund,
          github_access_revoked: accessRevoked,
        });
      } catch (error) {
        console.error(`无法将退款信息记录到PostHog系统中:`, error);
      }
    });

    // 第五步:通知客户
    await step.run("send-customer-notification", async () => {
      if (isFullRefund) {
        await sendEmail({
          to: user.email,
          subject: `您的${brand.name}退款申请已处理完毕`,
          template: createElement(AccessRevokedEmail, {
            customerEmail: user.email,
            refundAmount: amountRefunded,
            currency,
          }),
        });
      } else {
        await sendEmail({
          to: user.email,
          subject: `您的${brand.name}部分退款申请已处理完毕`,
          template: createElement(PartialRefundEmail, {
            customerEmail: user.email,
            refundAmount: amountRefunded,
            originalAmount,
            currency,
          }),
        });
      }
    });

    // 第六步:通知管理员
    await step.run("send-admin-notification", async () => {
      const adminEmail = process.env ADMIN_EMAIL;
      if (!adminEmail) return;

      await sendEmail({
        to: adminEmail,
        subject: `\({isFullRefund ? "全额退款" : "部分退款"}退款申请已处理完毕: \){user.email}`,
        template: createElement(AdminRefundNotificationEmail, {
          customerEmail: user.email,
          customerName: user.name,
          githubUsername: user.githubUsername,
          refundAmount: amountRefunded,
          originalAmount,
          currency,
          stripeChargeId: chargeId,
          accessRevoked,
          isPartialRefund: !isFullRefund,
        }),
      });
    });

    return { success: true, accessRevoked, isFullRefund, userId: user.id };
  }
);

全额退款与部分退款的区别

这个功能通过简单的比较来区分这两种退款方式:

const isFullRefund = amountRefunded >= originalAmount;

对于全额退款,会发生以下三件事:

  1. GitHub访问权限会被取消(会执行`removeCollaborator`操作)。

  2. 购买状态会被设置为“已退款”。

  3. 客户会收到一封《访问权限已被取消的邮件》,告知他们的访问权限已经被移除。

而对于部分退款,客户的访问权限不会被取消:

  1. GitHub访问权限不会被取消。

  2. 购买状态会被设置为“部分退款”。

  3. 客户会收到一封《部分退款通知邮件》,其中会明确说明退款的金额以及原购物金额。

这种区分对于维护数据库的完整性非常重要。后端系统(如仪表盘、分析工具和支持系统)需要准确的状态信息。如果一笔交易被标记为“部分退款”,那么这个客户仍然被视为活跃用户。

条件步骤的工作原理

只有当以下三个条件都满足时,才会执行“取消GitHub访问权限”这一操作:这笔交易是全额退款,用户拥有GitHub用户名,并且之前确实被授予了访问权限。

if (isFullRefund && user.githubUsername && purchase.githubAccessGranted) {
  const revokeResult = await step.run("revoke-github-access", async () => {
    return removeCollaborator(user.githubUsername!);
  });
  accessRevoked = revokeResult.success;
}

如果其中任何一个条件不满足,这个步骤就会被完全跳过。Inngest能够很好地处理这种情况:函数会继续执行第3步(更新购买状态),此时`accessRevoked`的值仍然为`false`。

如何恢复被放弃的购物流程

当客户开始购物流程但未完成时,Stripe系统会在24小时后自动终止该会话。你可以监听这一事件,并向客户发送恢复提醒邮件。

关键在于不要立即发送邮件——给客户一小时的时间让他们自己回来继续完成购物流程。

// src/lib/jobs/functions/stripe.ts
export const handleCheckoutExpired = inngest.createFunction(
  {
    id: "checkout-expired",
    triggers: [{ event: "stripe/checkout.session.expired" }],
  },
  async ({ event, step }) => {
    const { customerEmail, sessionId } = event.data as {
      customerEmail: string | null;
      sessionId: string;
    };

    if (!customerEmail) {
      return { success: false, reason: "no_email" };
    }

    // 等待1小时后再发送恢复邮件
    await step.sleep("wait-before-recovery-email", "1h");

    // 发送放弃购物篮的提醒邮件
    await step.run("send-abandoned-cart-email", async () => {
      const baseUrl =
        process.env.BETTER_AUTH_URL ?? "https://your-app.com";
      const checkoutUrl = `${baseUrl}/pricing`;

      await sendEmail({
        to: customerEmail,
        subject: `您的${brand.name}购物流程已暂停,请尽快继续`,
        template: createElement(AbandonedCartEmail, {
          customerEmail,
          checkoutUrl,
        }),
      });
    });

    // 记录恢复尝试的结果
    await step.run("track-abandoned-cart", async () => {
      try {
        await trackServerEvent("anonymous", "abandoned_cart_email_sent", {
          customer_email: customerEmail,
          session_id: sessionId,
        });
      } catch (error) {
        console.error(`无法向PostHog系统记录恢复尝试信息:`, error);
      }
    });

    return { success: true, customerEmail };
  }
);

step.sleep("wait-before-recovery-email", "1h")这条代码会让函数暂停运行一小时,而不会消耗任何计算资源。系统会安排在延迟时间过后重新执行该函数。这种方式不需要使用cron作业、Redis队列,也不会因为服务器重启而导致setTimeout失效。

在函数的开始部分还添加了检测机制:如果客户的结账信息中没有填写电子邮件地址(也就是客户在输入地址之前就关闭了页面),那么函数会立即结束执行。没有地址的话,就无法发送恢复邮件。

你还可以在这个流程中增加额外的延迟步骤,比如在三天后再次发送提醒邮件;或者通过step.run()查询数据库,确认客户是否已经完成了购买操作,如果已经完成购买,则可以跳过这次邮件通知。

为什么一小时是最佳的延迟时间

在结账期限刚过就立即发送恢复邮件,会给客户带来压力。此时客户可能还在比较不同的选项、等待发薪日,或者只是暂时分心了。这样的即时通知会让人感觉像是在被监视。

如果等待24小时再发送邮件,那就太晚了——客户可能已经忘记了你的产品,或者找到了其他替代品。

通过测试我发现,一小时是最佳的延迟时间。在这个时间段内,客户的购买意图仍然清晰,收到邮件也会觉得这是一种有帮助的行为,而不会让人感到被强行打扰。

不过,不同情况下这个延迟时间可能会有所不同。你可以通过修改"1h""30m""3h"来调整延迟时长,然后重新部署代码即可。

为什么这种方式比cron作业更好

如果缺乏可靠的执行机制,弃用购物车的恢复功能通常会这样运作: cron作业每小时运行一次,从数据库中查询那些尚未被恢复的过期订单信息,然后给每个订单发送邮件,并将其标记为已恢复。

这种做法存在很多问题。首先需要设置recovered_at字段以避免重复发送邮件;其次,如果cron作业在处理过程中崩溃,也需要妥善处理这个问题;最后,还需要仔细调整cron任务的执行间隔。

step.sleep()这种方法彻底解决了这些问题。每个过期订单都会对应一个独立的函数实例和计时器,因此不存在批量处理、数据库标记或重复发送邮件的风险。

如何使用React Email发送事务性邮件

支付流程中的每封邮件都是通过React组件渲染成HTML格式后再通过Resend功能发送出去的。这种方式能够确保模板使用的类型安全,同时支持组件的复用,并且可以在开发过程中在浏览器中预览邮件的效果。

如何配置邮件发送客户端

邮件发送客户端通过一个简单的sendEmail函数来封装Resend的功能:

// src/lib/email/index.ts
import { render } from "@react-email/components";
import type { ReactElement } from "react";
import { Resend } from "resend";

import { brand } from "@/lib/brand";

let resendClient: Resend | null = null;

function getResend(): Resend {
  if (!resendClient) {
    const apiKey = process.env.RESEND_API_KEY;
    if (!apiKey) {
      throw new Error("RESEND_API_KEY is not set");
    }
    resendClient = new Resend(apiKey);
  }
  return resendClient;
}

interface SendEmailOptions {
  to: string | string[];
  subject: string;
  template: ReactElement;
  from?: string;
  replyTo?: string;
}

export async function sendEmail({
  to,
  subject,
  template,
  from = process.env.email_FROM ?? brand.emails.from,
  replyTo,
}: SendEmailOptions) {
  const resend = getResend();
  const html = await render(template);

  return resend.emails.send({
    from,
    to,
    subject,
    html,
    replyTo,
  });
}

@react-email/components中的render()函数会将一个React元素转换为HTML字符串。而Resend正是将这种HTML格式的内容发送到客户的收件箱中。
from地址默认使用您品牌所配置的电子邮件地址。为了使这一功能正常工作,您需要在Resend系统中使用已验证的域名。在开发阶段,Resend的免费版本允许您直接使用自己的电子邮件地址进行发送,而无需进行域名验证。

如何制作购买确认邮件模板

以下是真实的购买确认邮件模板:

// src/lib/email/emails/purchase-confirmation.tsx
import {
  Body,
  Container,
  Head,
  Heading,
  Hr,
  Html,
  Link,
  Preview,
  Section,
  Text,
} from "@react-email/components";

import { brand } from "@/lib/brand";

interface PurchaseConfirmationEmailProps {
  amount: number;
  currency: string;
  customerEmail: string;
}

const colors = {
  primary: "#d97757",
  background: "#faf9f5",
  foreground: "#30302e",
  muted: "#6b6860",
  border: "#e5e4df",
  card: "#ffffff",
  success: "#16a34a",
  successLight: "#f0fdf4",
};

export default function PurchaseConfirmationEmail({
  amount,
  currency,
  customerEmail,
}: PurchaseConfirmationEmailProps) {
  const formattedAmount = new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: currency.toUpperCase(),
  }).format(amount / 100);

  return (
    
      
      您的{brand.name}购买已确认!
      
        
          
{brand.name}

支付成功
感谢您的购买! 您的付款已经成功处理完毕。我们目前正在为您设置GitHub仓库的访问权限。不久之后,您会收到另一封邮件,其中包含访问链接。
订单详情
产品 {brand.name}
金额 {formattedAmount}
电子邮件地址 {customerEmail}

对您的购买有疑问吗?请回复这封邮件或联系{" "} {brand.emails.support}
); } PurchaseConfirmationEmail.PreviewProps = { amount: 9900, currency: "usd", customerEmail: "customer@example.com", } satisfies PurchaseConfirmationEmailProps;

关于这个模板,有几点需要注意。

  • 货币格式的处理是在模板中完成的: `amount` 属性的值是以分为单位表示的(这与你的数据库中存储的数据以及 Stripe 返回的数据格式一致)。通过调用 `Intl.NumberFormat`,这些数值会被转换成人类可读的字符串形式,例如 “$99.00”,同时将所有的货币格式处理逻辑集中放在一个地方。

  • `PreviewProps` 对象仅用于开发用途。 React Email 使用这些属性在浏览器中渲染预览效果。`satisfies` 关键字确保这些预览相关属性与组件的接口要求相匹配。

  • 所有的样式都是内联样式。 电子邮件客户端会删除 `