大多數結帳流程睇落係咁樣:顧客填寫資料,撳確認,被重新導向去支付頁面,再返嚟。聽落夠直觀。但呢個流程入面藏住一個結構性問題——訂單喺顧客撳確認嗰一刻就已經被建立,而呢陣時仲未有人真正俾過錢。
呢個意味住,每一次被放棄嘅結帳、每一次失敗嘅支付、每一個被隨手關咗嘅瀏覽器分頁,都會喺你嘅數據庫入面留低一條真實嘅記錄。日子耐咗,你就會累積一堆「鬼訂單」——呢啲記錄從來都唔係真正嘅交易,但而家卻需要被追蹤、過期處理同清理。如果你嘅系統喺訂單建立時就分配庫存,咁呢啲鬼訂單就一直霸住本來可以賣畀第二個人嘅庫存。
支付優先模型將呢個順序倒轉過嚟。喺支付確認之前,唔會建立任何訂單。下面就係佢喺實際中嘅樣。
購物車快照
與其持久化一個訂單,不如持久化一份重建訂單所需嘅一切資訊嘅快照。呢份快照同支付會話一齊存在,一旦支付有咗結果(無論邊種結果),就會被丟棄。
// 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嗰篇文章度提到嘅、值得構建嘅網關無關輪詢端點嘅價值所在。或者,你都可以接受呢兩個網關會透過唔同嘅路徑生成訂單,轉而喺訂單層面去做協調。
唔管揀邊種方式,提前對呢個決定深思熟慮,都可以喺後期慳返大量返工嘅功夫。