PAYATHON 2026

如何建模 e-commerce website checkout 流程

支付阿杰

结论

购物车、订单、发票和支付尝试有各自的生命周期,不应混在一起建模。

建议采用以下方式:

  • Cart 或 CheckoutSession 保存结账期间仍可修改的数据。
  • 用户确认购买后,将当时的结账数据冻结为 Order 快照。
  • 一个 Order 对应一笔逻辑上的 Payment,每次实际扣款则记录为单独的 PaymentAttempt。
  • 用户重复点击或网络重试时,根据 idempotency key 返回已有结果,不重复创建对象。
  • 金额和订单内容确定后再生成 Invoice,生成后不可修改。
  • 开票后如需调整订单,不要修改原发票。应作废、红冲或开具调整单,再生成新的订单版本和发票。

采用这种模型后,支付可以安全重试,客户也可以返回购物车修改内容,同时避免把一次技术重试记录成多个订单或多张发票。

分离不同生命周期

问题不在于应该“创建”还是“更新”,而在于流程中混入了几类含义不同的数据。

Cart / CheckoutSession

这里保存用户仍在编辑的内容,例如:

  • 商品和数量
  • 收货地址
  • shipping method
  • 优惠券
  • 税费和运费计算结果
  • 当前 checkout 步骤

它是可变对象,不适合承担正式订单或财务凭证的职责。

Order

Order 是客户确认购买时生成的业务快照。它应保存当时的商品名称、单价、数量、税费、运费和总金额,不能只引用购物车中的实时数据。

订单状态可以变化,例如:

PENDING_PAYMENT
PAID
PAYMENT_FAILED
CANCELLED
EXPIRED

状态可以更新,但确认时的金额和订单行通常不应原地改写。

Invoice

Invoice 是财务凭证,不是保存付款过程临时状态的容器。具体何时开票,要看业务规则和所在地区的财税要求。

如果业务规则允许,可以在支付成功后开具正式发票。这样一来,支付失败不会产生需要额外处理的正式发票。

如果必须先开票再付款,发票生成后就不能修改。订单发生变化时,应按照适用的财务规则处理:

  • 作废原发票;
  • 开具 credit note、红字发票或其他调整凭证;
  • 为新的订单版本生成新发票。

具体使用哪种方式,需要根据当地法规和会计规则确定。

Payment 与 PaymentAttempt

Payment 表示订单需要完成的一笔逻辑付款,例如“为订单 10001 支付 500 元”。

PaymentAttempt 表示一次实际调用 payment gateway 的操作。第一次信用卡扣款失败后,客户更换卡片再次付款,应记录为同一个 Payment 下的两次尝试,而不是两个订单。

推荐的关系如下:

CheckoutSession
    └── Order
          ├── Invoice
          └── Payment
                ├── PaymentAttempt #1 FAILED
                └── PaymentAttempt #2 SUCCEEDED

如果现有系统只能使用一张 Payment 表,也可以让每次扣款生成一条 Payment 记录。不过,需要明确这些记录实际代表 payment attempts,并通过 order_id 或 payment_group_id 归入同一笔应付款。单独建立 PaymentAttempt 模型通常更容易理解。

推荐的结账流程

1. 编辑结账会话

步骤 1 至步骤 4 只更新 CheckoutSession。每当商品、地址或配送方式发生变化,都要重新计算价格并增加版本号:

checkout_session.version = 7
checkout_session.total = 500.00

确认页面展示的信息必须与这个版本一致。

2. 提交确认请求

客户端提交:

{
  "checkoutSessionId": "cs_123",
  "checkoutVersion": 7,
  "idempotencyKey": "6f72b606-9a56-4a21-b6e4-621c72a24d50",
  "paymentMethodToken": "pm_token_abc"
}

服务端需要检查:

  • checkout session 是否仍然有效;
  • 客户端提交的版本是否与服务器当前版本一致;
  • 库存、价格、优惠和 shipping method 是否仍然有效;
  • idempotency key 是否已经处理。

如果版本已经改变,应拒绝本次确认,并让客户重新检查订单,不能按旧金额扣款。

3. 创建并冻结订单

在数据库事务中完成以下操作:

  • 根据 CheckoutSession 创建 Order 快照;
  • 创建逻辑上的 Payment;
  • 记录 idempotency key;
  • 将 CheckoutSession 标记为已提交。

关键字段需要添加唯一约束,例如:

CREATE UNIQUE INDEX uq_order_checkout_version
ON orders(checkout_session_id, checkout_version);

CREATE UNIQUE INDEX uq_checkout_confirm_idempotency
ON checkout_confirmations(idempotency_key);

这样,即使用户双击按钮、浏览器重新发送请求,或者两个应用实例同时处理请求,数据库也只会创建一个对应的订单。

4. 执行支付

不要让数据库事务一直等待外部 payment gateway。事务提交后,再创建 PaymentAttempt 并调用支付服务。

Payment
  status = PENDING
  amount = 500.00

PaymentAttempt
  status = PROCESSING
  attempt_no = 1

调用支付服务时,也要传入稳定的 idempotency key,例如:

payment:{payment_id}:attempt:{attempt_no}

支付失败后更新为:

Payment.status = REQUIRES_PAYMENT_METHOD
PaymentAttempt.status = FAILED

客户再次尝试付款时,继续使用原来的 Order 和 Payment,只新增一个 PaymentAttempt。

支付成功后更新为:

Payment.status = SUCCEEDED
PaymentAttempt.status = SUCCEEDED
Order.status = PAID

随后创建或确认 Invoice,并跳转到 “Thank you” 页面。

订单修改如何处理

处理方式取决于修改发生的时间。

尚未生成 Order

直接修改 CheckoutSession 并重新计算金额。由于正式订单还没有生成,不涉及更新订单或发票。

已创建 Order,但支付尚未成功

不要直接修改已经冻结的订单内容。

如果客户返回购物车修改商品,应按以下步骤处理:

  1. 取消原订单或将其标记为过期;
  2. 取消原 Payment,并阻止它继续扣款;
  3. 更新 CheckoutSession,或创建新的版本;
  4. 客户再次确认时创建新订单。

这不属于“重复订单”,而是两次内容不同的购买提案。原订单应保留,并标记为 CANCELLED 或 SUPERSEDED,以便审计和排查问题。

还可以记录新旧订单之间的替代关系:

old_order.status = SUPERSEDED
old_order.superseded_by_order_id = new_order.id

如果原订单还没有生成正式发票,就无需处理发票变更。

支付成功或已经正式开票

到了这个阶段,通常不能再把原订单当作购物车修改。后续变化应进入取消、退款、退货或换货流程。

已经开具的 Invoice 保持不变,金额调整通过相应的冲销或调整凭证处理。不能覆盖原记录来“修正历史”。

数据模型示例

CREATE TABLE checkout_sessions (
    id                  UUID PRIMARY KEY,
    customer_id         UUID NOT NULL,
    version             INTEGER NOT NULL,
    status              VARCHAR(32) NOT NULL,
    currency            CHAR(3) NOT NULL,
    total_amount        DECIMAL(18, 2) NOT NULL,
    expires_at          TIMESTAMP NOT NULL
);

CREATE TABLE orders (
    id                   UUID PRIMARY KEY,
    order_number         VARCHAR(64) NOT NULL UNIQUE,
    checkout_session_id  UUID NOT NULL,
    checkout_version     INTEGER NOT NULL,
    customer_id          UUID NOT NULL,
    status               VARCHAR(32) NOT NULL,
    currency             CHAR(3) NOT NULL,
    total_amount         DECIMAL(18, 2) NOT NULL,
    superseded_by_id     UUID NULL,
    created_at           TIMESTAMP NOT NULL,
    UNIQUE (checkout_session_id, checkout_version)
);

CREATE TABLE invoices (
    id              UUID PRIMARY KEY,
    invoice_number  VARCHAR(64) NOT NULL UNIQUE,
    order_id        UUID NOT NULL,
    status          VARCHAR(32) NOT NULL,
    currency        CHAR(3) NOT NULL,
    total_amount    DECIMAL(18, 2) NOT NULL,
    issued_at       TIMESTAMP NOT NULL,
    UNIQUE (order_id)
);

CREATE TABLE payments (
    id              UUID PRIMARY KEY,
    order_id        UUID NOT NULL,
    status          VARCHAR(32) NOT NULL,
    currency        CHAR(3) NOT NULL,
    amount          DECIMAL(18, 2) NOT NULL,
    created_at      TIMESTAMP NOT NULL,
    UNIQUE (order_id)
);

CREATE TABLE payment_attempts (
    id                    UUID PRIMARY KEY,
    payment_id            UUID NOT NULL,
    attempt_no            INTEGER NOT NULL,
    status                VARCHAR(32) NOT NULL,
    provider_reference    VARCHAR(255),
    provider_idempotency_key VARCHAR(255) NOT NULL UNIQUE,
    failure_code          VARCHAR(128),
    created_at            TIMESTAMP NOT NULL,
    UNIQUE (payment_id, attempt_no)
);

订单行也要复制客户确认时的商品快照:

CREATE TABLE order_lines (
    id                 UUID PRIMARY KEY,
    order_id           UUID NOT NULL,
    product_id         UUID NOT NULL,
    product_name       VARCHAR(255) NOT NULL,
    sku                VARCHAR(128) NOT NULL,
    unit_price         DECIMAL(18, 2) NOT NULL,
    quantity           INTEGER NOT NULL,
    tax_amount         DECIMAL(18, 2) NOT NULL,
    line_total         DECIMAL(18, 2) NOT NULL
);

不能只通过当前的 product.price 还原历史订单,因为商品名称和价格以后可能会变化。

服务端伪代码

async function confirmCheckout(command: ConfirmCheckoutCommand) {
  const previous = await confirmationRepository.findByIdempotencyKey(
    command.idempotencyKey
  );

  if (previous) {
    return previous.result;
  }

  const result = await database.transaction(async (tx) => {
    const checkout = await tx.checkoutSessions.lock(command.checkoutSessionId);

    if (checkout.version !== command.checkoutVersion) {
      throw new CheckoutChangedError();
    }

    if (checkout.status === "SUBMITTED") {
      return tx.orders.findByCheckoutVersion(
        checkout.id,
        checkout.version
      );
    }

    await validatePriceInventoryAndShipping(checkout);

    const order = await tx.orders.createFromSnapshot(checkout, {
      status: "PENDING_PAYMENT"
    });

    const payment = await tx.payments.create({
      orderId: order.id,
      amount: order.totalAmount,
      currency: order.currency,
      status: "PENDING"
    });

    await tx.checkoutSessions.markSubmitted(checkout.id);

    await tx.confirmations.create({
      idempotencyKey: command.idempotencyKey,
      orderId: order.id
    });

    return { order, payment };
  });

  await startPaymentAttempt(
    result.payment.id,
    command.paymentMethodToken
  );

  return result;
}

支付重试可以这样处理:

async function retryPayment(
  orderId: string,
  paymentMethodToken: string,
  idempotencyKey: string
) {
  const order = await orderRepository.get(orderId);

  if (order.status === "PAID") {
    return order;
  }

  if (order.status !== "PENDING_PAYMENT" &&
      order.status !== "PAYMENT_FAILED") {
    throw new OrderNotPayableError();
  }

  const payment = await paymentRepository.getByOrderId(orderId);

  const existingAttempt =
    await paymentAttemptRepository.findByIdempotencyKey(idempotencyKey);

  if (existingAttempt) {
    return existingAttempt;
  }

  return createAndExecutePaymentAttempt(
    payment,
    paymentMethodToken,
    idempotencyKey
  );
}

并发和异常处理

只在页面上禁用 “Confirm” 按钮并不能解决并发问题。双击、超时重试、反向代理重发和 webhook 重复投递都可能产生重复请求。

系统至少需要做好以下保护:

  • 确认请求使用 idempotency key;
  • 使用数据库唯一约束作为最后一道保护;
  • 调用 payment gateway 时使用其提供的 idempotency 机制;
  • 根据 provider event ID 对 webhook 去重;
  • 通过条件更新或行锁更新状态;
  • 在服务端校验金额、币种、订单号和 provider reference;
  • 前端请求超时后,不能直接认定支付失败。

支付接口超时并不代表扣款失败,实际结果可能是成功、失败或未知。此时应将尝试标记为 UNKNOWN,或继续保留 PROCESSING 状态,再通过 webhook 或主动查询确认结果。结果明确之前,不能立即发起另一笔相同扣款。

关于 Invoice 与 Payment 的关系

通常让 Invoice 和 Payment 分别关联 Order,比强制要求 Payment 只能关联 Invoice 更灵活:

Order ── Invoice
  │
  └──── Payment ── PaymentAttempt

支付可能发生在正式发票创建之前,而且一个订单可能涉及:

  • 多次支付尝试;
  • 分次付款;
  • 多种 payment method;
  • 部分退款;
  • 多张发票或调整凭证。

如果现有业务明确规定“一张发票对应一笔付款”,可以保留 Payment.invoice_id,但不能让这个关系决定支付重试的生命周期。

需要避免的做法

  • 每次点击 “Confirm” 都无条件创建 Order。
  • 用“先查询是否存在,再插入”代替数据库唯一约束。
  • 为了配合购物车变化而修改已经开具的 Invoice。
  • 覆盖失败的支付记录,导致每次尝试无法审计。
  • 调用 payment gateway 时长时间占用数据库事务。
  • 支付请求超时后立即再次扣款。
  • 依赖前端传入的价格或支付状态。
  • 为了让数据看起来“整洁”而删除失败订单。

可编辑的数据应保存在 CheckoutSession 中,客户确认后的业务事实写入 Order,财务事实写入不可修改的 Invoice,外部扣款过程中产生的每次尝试则由 PaymentAttempt 完整记录。每个对象只负责一种生命周期,支付重试与订单修改也就不会互相干扰。

备注:内容仅供参考。