大多数结账流程看起来是这样的:顾客填写信息,点击确认,被重定向到支付页面,再返回。听起来足够直观。但这个流程里藏着一个结构性问题——订单在顾客点击确认的那一刻就被创建了,而这时候还没有人真正付过款。
这意味着,每一次被放弃的结账、每一次失败的支付、每一个被随手关掉的浏览器标签页,都会在你的数据库里留下一条真实的记录。久而久之,你就会积累一堆"幽灵订单"——这些记录从来都不是真正的交易,但现在却需要被追踪、过期处理和清理。如果你的系统在订单创建时就分配库存,那这些幽灵订单就一直占着本来可以卖给别人的库存。
支付优先模型把这个顺序颠倒了过来。在支付确认之前,不会创建任何订单。下面是它在实际中的样子。
购物车快照
与其持久化一个订单,不如持久化一份重建订单所需的一切信息的快照。这份快照和支付会话一起存在,一旦支付有了结果(无论哪种结果),就会被丢弃。
// checkout-session.entity.ts
@Entity()
export class CheckoutSession {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
session_token: string; // used as the txnid with the gateway
@Column('jsonb')
cart_snapshot: {
items: { product_id: number; variant_id: number; quantity: number; unit_price: number }[];
shipping_address: AddressSnapshot;
shipping_method: string;
shipping_fee: number;
voucher_ids: number[];
};
@Column({ nullable: true })
user_id: number;
@Column({ default: 'pending' })
status: 'pending' | 'paid' | 'failed' | 'expired';
@CreateDateColumn()
created_at: Date;
@Column({ type: 'timestamptz' })
expires_at: Date; // set to ~30 minutes from creation
}
这份快照有意保存了反规范化的数据。价格、商品名称、运费都可能会变。快照记录的是顾客在结账那一刻所同意的内容,而不是数据库现在的最新内容。
发起结账
当顾客确认购物车时,你创建一个会话,并把他们送到支付网关。这时候还没有订单。
// checkout.service.ts
async initiateCheckout(userId: number, input: CheckoutInput): Promise<{ checkoutUrl: string; sessionToken: string }> {
// Validate stock availability before creating the session
for (const item of input.items) {
const variant = await this.variantRepo.findOneOrFail({ where: { id: item.variant_id } });
if (variant.stock < item.quantity) {
throw new BadRequestException(`Insufficient stock for ${variant.sku}`);
}
}
const sessionToken = randomUUID();
const expiresAt = addMinutes(new Date(), 30);
await this.sessionRepo.save({
session_token: sessionToken,
user_id: userId,
status: 'pending',
expires_at: expiresAt,
cart_snapshot: {
items: input.items,
shipping_address: input.shipping_address,
shipping_method: input.shipping_method,
shipping_fee: input.shipping_fee,
voucher_ids: input.voucher_ids ?? [],
},
});
// Pass the session token as the transaction reference to the gateway
const checkoutUrl = await this.dragonpayService.initiate({
txnid: sessionToken,
amount: this.computeTotal(input),
description: `Order for user ${userId}`,
email: input.email,
});
return { checkoutUrl, sessionToken };
}
注意,这里检查了库存,但并没有预留库存。这是一个刻意的取舍——为了避免为那些永远不会完成的会话占用库存。缺点是,两位顾客都可能对同一件"最后库存"通过这个检查。支付回调会明确处理这种竞态条件。
回调:订单真正被创建的地方
重心在这里发生转移。回调不再是简单地翻转一个已有订单的状态标志,而是必须从快照中重建整个订单。
// checkout.service.ts
async handlePaymentCallback(sessionToken: string, gatewayStatus: string): Promise {
const session = await this.sessionRepo.findOne({ where: { session_token: sessionToken } });
if (!session) throw new NotFoundException('Session not found');
if (session.status !== 'pending') return; // idempotency, duplicate callbacks do nothing
if (gatewayStatus !== 'S') { // DragonPay success code
session.status = 'failed';
await this.sessionRepo.save(session);
return;
}
// Re-validate stock at the moment of payment confirmation
for (const item of session.cart_snapshot.items) {
const variant = await this.variantRepo.findOneOrFail({
where: { id: item.variant_id },
lock: { mode: 'pessimistic_write' }, // row-level lock to handle concurrent callbacks
});
if (variant.stock < item.quantity) {
// Payment came in but stock is gone, needs manual resolution or refund trigger
session.status = 'failed';
await this.sessionRepo.save(session);
this.notifyStockConflict(session);
return;
}
variant.stock -= item.quantity;
await this.variantRepo.save(variant);
}
// Now create the order
const order = await this.orderRepo.save({
user_id: session.user_id,
status: 'confirmed',
shipping_address: session.cart_snapshot.shipping_address,
shipping_method: session.cart_snapshot.shipping_method,
shipping_fee: session.cart_snapshot.shipping_fee,
items: session.cart_snapshot.items.map(i => ({
product_id: i.product_id,
variant_id: i.variant_id,
quantity: i.quantity,
unit_price: i.unit_price,
})),
payment_reference: sessionToken,
});
// Apply vouchers if any
if (session.cart_snapshot.voucher_ids.length) {
await this.voucherService.applyToOrder(order.id, session.cart_snapshot.voucher_ids);
}
session.status = 'paid';
await this.sessionRepo.save(session);
}
第8行的幂等性检查非常关键。支付网关有可能不止一次触发同一个回传,DragonPay尤其如此。没有这个检查,重试时就会创建重复订单。会话的状态起到了闸门的作用:一旦它不再是pending状态,回调就会提前退出。
会话过期
待处理的会话需要按计划定期清理。一个NestJS的cron任务负责处理这件事:
// checkout-expiry.task.ts
@Cron(CronExpression.EVERY_10_MINUTES)
async expirestaleSessions(): Promise {
await this.sessionRepo
.createQueryBuilder()
.update(CheckoutSession)
.set({ status: 'expired' })
.where('status = :status', { status: 'pending' })
.andWhere('expires_at < NOW()')
.execute();
}
过期的会话会被保留用于审计目的——你可以查询它们来了解流失率,而不会污染你的订单表。
这个模型不适用的情况
当支付网关会触发一个你可以据此采取行动的服务器端回传时,支付优先模型就能干净利落地工作,DragonPay的模式就很符合这一点。PayMongo的运作方式不同:你先创建一个支付意向(payment intent),顾客付款,然后触发一个webhook。这个意向绑定在一个你已经生成好的订单ID上,所以PayMongo实际上是在隐性地把你推向订单优先的模式。
如果你同时支持这两种网关,有几个选择。你可以把结账会话当作一个通用的抽象层,不管是哪个网关触发的,都从这个会话重建订单——这也正是PayMongo那篇文章里提到的、值得构建的网关无关轮询端点的价值所在。或者,你也可以接受这两个网关会通过不同的路径生成订单,转而在订单层面去做协调。
不管选哪种方式,提前对这个决定深思熟虑,都能在后期省下大量返工的功夫。