Thiết kế hệ thanh toán/checkout (idempotent, saga)
Cách xây hệ thanh toán trong .NET: idempotency key, saga orchestration, ngăn double-charge, luồng refund, và tích hợp Stripe/Adyen.
Mục lục
Hệ thanh toán là nơi idempotency, saga, outbox, và handler resilience đều xuất hiện cùng lúc. Sai cái nào khách bị charge hai lần hoặc kẹt với đơn đã trả-mà-chưa-giao. Chương này thiết kế luồng checkout trong .NET sống sót glitch mạng, bão webhook, và crash orchestrator - và giải thích vì sao mọi block trong series trả lại ở đây.
Khi nào payment thành service riêng?
Ba tín hiệu.
Nhiều sản phẩm dồn vào một checkout. Marketplace, SaaS đa tenant, hay e-commerce có subscription và mua một lần. Tập trung logic payment dừng ba cài đặt drift lệch theo cách tinh vi và đắt.
Yêu cầu tuân thủ. Phạm vi PCI DSS giảm mạnh nếu data thẻ không bao giờ chạm server (dùng Stripe Elements / Adyen Web Drop-In). Service payment là ranh giới rõ ràng.
Refund và đối soát là thao tác hạng nhất. Người ops thủ công cần refund, retry, trace charge. Service chuyên với audit trail và admin UI làm cái đó scale.
Số nào nên ngân sách?
Checkout / ngày 100K
Avg amount $50
Latency provider 300-1000 ms p99
Tỉ lệ webhook trùng 1-5% (Stripe retry)
Cửa sổ idempotency 24-72 giờ
Cửa sổ refund 90 ngày điển hình
Giữ audit log 7 năm (record tài chính)
Hai số dẫn dắt mọi thứ: latency provider ép UX async (poll hay webhook để xác nhận), và webhook trùng làm idempotency bắt buộc. Giữ audit định hình quyết định storage.
Kiến trúc trông thế nào?
flowchart LR
Client --> Web[ASP.NET Core checkout]
Web --> SagaOrch[Saga Orchestrator]
SagaOrch -->|reserve| Inv[Service inventory]
SagaOrch -->|authorise| Pay[Service payment]
Pay --> Stripe[(API Stripe)]
Stripe -. webhook .-> Web
SagaOrch --> OB[(Outbox)]
OB --> Notif[Service notification]
SagaOrch --> Audit[(Log audit)]
Stripe -. webhook async .-> Web
Năm service. Web nhận checkout, saga orchestrator chạy bước, payment nói với Stripe, inventory giữ stock, notification fan out email. Log audit bắt mọi đổi state. Webhook quay lại bất đồng bộ và update saga.
Cấu hình .NET 10 cho saga?
public class PaymentSaga : MassTransitStateMachine<PaymentSagaState>
{
public State Reserving { get; private set; } = null!;
public State Authorising { get; private set; } = null!;
public State Capturing { get; private set; } = null!;
public State Completed { get; private set; } = null!;
public State Failed { get; private set; } = null!;
public Event<CheckoutStarted> Started { get; private set; } = null!;
public Event<InventoryReserved> Reserved { get; private set; } = null!;
public Event<PaymentAuthorised> Authorised { get; private set; } = null!;
public Event<PaymentCaptured> Captured { get; private set; } = null!;
public Event<PaymentFailed> Failed { get; private set; } = null!;
public PaymentSaga()
{
InstanceState(x => x.CurrentState);
Initially(
When(Started)
.Then(ctx => { ctx.Saga.IdempotencyKey = ctx.Message.IdempotencyKey;
ctx.Saga.Amount = ctx.Message.Amount; })
.Publish(ctx => new ReserveInventory(ctx.Saga.OrderId, ctx.Message.Items))
.TransitionTo(Reserving));
During(Reserving,
When(Reserved).Publish(ctx => new AuthorisePayment(
ctx.Saga.IdempotencyKey, ctx.Saga.Amount))
.TransitionTo(Authorising));
During(Authorising,
When(Authorised).Publish(ctx => new CapturePayment(ctx.Saga.IdempotencyKey))
.TransitionTo(Capturing),
When(Failed).Publish(ctx => new ReleaseInventory(ctx.Saga.OrderId))
.TransitionTo(this.Failed));
During(Capturing,
When(Captured).TransitionTo(Completed));
}
}
Call Stripe trong consumer AuthorisePayment:
public class AuthorisePaymentConsumer(IStripeClient stripe, AppDbContext db)
: IConsumer<AuthorisePayment>
{
public async Task Consume(ConsumeContext<AuthorisePayment> ctx)
{
var options = new PaymentIntentCreateOptions
{
Amount = (long)(ctx.Message.Amount * 100),
Currency = "usd",
ConfirmationMethod = "automatic",
};
var requestOptions = new RequestOptions
{
IdempotencyKey = ctx.Message.IdempotencyKey.ToString() // dedup phía Stripe
};
var intent = await stripe.PaymentIntents.CreateAsync(options, requestOptions);
if (intent.Status == "requires_action") { /* 3DS challenge */ }
await ctx.Publish(new PaymentAuthorised(ctx.Message.IdempotencyKey, intent.Id));
}
}
Ba chi tiết. Cùng idempotency key chảy từ client tới service tới Stripe, dedup mỗi tầng. Saga orchestrator persist state mỗi transition. Update webhook từ Stripe publish vào cùng saga qua correlation theo payment intent ID.
Webhook tích hợp với saga ra sao?
app.MapPost("/webhooks/stripe", async (HttpRequest req, IPublishEndpoint bus,
AppDbContext db, IConfiguration config) =>
{
var json = await new StreamReader(req.Body).ReadToEndAsync();
var sig = req.Headers["Stripe-Signature"].ToString();
var stripeEvent = EventUtility.ConstructEvent(json, sig,
config["Stripe:WebhookSecret"]);
// Idempotency theo Stripe event ID
var seen = await db.WebhookEvents.AnyAsync(e => e.StripeEventId == stripeEvent.Id);
if (seen) return Results.Ok();
db.WebhookEvents.Add(new() { StripeEventId = stripeEvent.Id, ReceivedAt = DateTimeOffset.UtcNow });
await db.SaveChangesAsync();
if (stripeEvent.Type == "payment_intent.succeeded")
{
var intent = stripeEvent.Data.Object as PaymentIntent;
await bus.Publish(new PaymentCaptured(Guid.Parse(intent!.Metadata["idempotencyKey"])));
}
return Results.Ok();
});
Check signature xác thực webhook. Bảng idempotency theo event ID dedup retry của Stripe. Message publish correlate ngược về saga qua idempotency key.
Đường scale-out hỗ trợ?
Hình giữ phần lớn ở scale. Cái đổi:
- DB state saga: partition theo user ID; saga mỗi user serialisable trên một partition.
- Handler webhook: stateless; scale replica. Stripe giới hạn rate vào theo retry, nên tải bị giới hạn.
- Log audit: append-only, partition theo tháng; archive sau cửa sổ giữ.
- Rate limit Stripe: ~25 req/s mỗi tài khoản; routing đa tài khoản là câu trả lời nếu vượt.
Cổ chai ở scale hiếm là code; là API provider thanh toán.
Tạo failure mode nào?
- Double charge - retry không idempotency. Phòng: enforce ba tầng (UUID client, idempotency server, idempotency Stripe).
- Charge nhưng không giao - capture xong, giao hàng fail.
Phòng: saga publish
RefundChargelàm bù; queue review thủ công bắt edge case. - Webhook lạc - Stripe gửi event endpoint không nhận. Phòng:
poll
payment_intents.listđêm cho charge chưa đối soát và replay event thiếu. - Bỏ giữa 3DS - user bắt đầu checkout, không hoàn 3D Secure. Phòng: timeout saga sau 30 phút, release kho, đánh dấu order abandoned.
Chương observability lộ metric saga-state nên cả bốn thấy được trong dashboard.
Khi nào service payment tuỳ là quá liều?
Khi bạn bán một sản phẩm với một provider và không có nhu cầu đối soát. Tích hợp Stripe Checkout trực tiếp với một webhook handler là đủ. Xây service chuyên khi phức tạp (đa sản phẩm, refund, UI ops, đa provider) biện minh được.
Đi tiếp đâu từ đây?
Case study cuối: pipeline event analytics
- bài firehose, nơi mọi pattern queue và stream của các chương kết hợp. Sau đó series khép với cách trả lời phỏng vấn và kết luận.
Câu hỏi thường gặp
Sao payment là case study khó nhất?
Idempotency chạy đầu cuối ra sao?
Idempotency-Key. Service check bảng idempotency chương 10; nếu có, trả response đã lưu. Cùng UUID forward sang Stripe làm idempotency key của họ. Phủ ba lớp: client retry, service retry, Stripe retry - không lần nào charge hai lần.Hình saga cho payment ra sao?
Xử webhook trùng từ Stripe ra sao?
Stripe-Signature và record idempotency theo event-ID trước khi xử. Lần đầu lưu event-ID; lần sau no-op. Stripe retry webhook quyết liệt; không idempotency, kho hoặc kế toán của bạn sẽ sai trong một tuần lên live.