如何建模 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,但支付尚未成功
不要直接修改已经冻结的订单内容。
如果客户返回购物车修改商品,应按以下步骤处理:
- 取消原订单或将其标记为过期;
- 取消原
Payment,并阻止它继续扣款; - 更新
CheckoutSession,或创建新的版本; - 客户再次确认时创建新订单。
这不属于“重复订单”,而是两次内容不同的购买提案。原订单应保留,并标记为 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 完整记录。每个对象只负责一种生命周期,支付重试与订单修改也就不会互相干扰。
备注:内容仅供参考。
