کلید Idempotency برای APIهای پرداخت در ASP.NET Core

پاسخ کوتاه: کلید idempotency یک مقدار یکتاست که کلاینت همراه درخواست می‌فرستد تا سرور تلاش دوباره را تشخیص دهد. اولین درخواست با یک کلید کار را انجام می‌دهد؛ هر درخواست بعدی با همان کلید، به‌جای برداشت، واریز یا انتقال دوباره، نتیجه اولی را پس می‌گیرد. در ASP.NET Core دو چیز لازم دارید: جست‌وجو بر اساس کلید، و یک ایندکس یکتا در دیتابیس که این جست‌وجو را در برابر race امن می‌کند.

یک مثال واقعی: کال‌بک دوباره

کاربری از طریق درگاه پرداخت ۵۰۰٬۰۰۰ ریال به کیف‌پولش واریز می‌کند. درگاه آدرس کال‌بک شما را صدا می‌زند تا پرداخت را تأیید کند. سرور شما کیف‌پول را شارژ می‌کند، ولی پاسخ دیر می‌رسد؛ درگاه timeout می‌شود و دوباره صدا می‌زند. همزمان کاربر یک spinner می‌بیند و دوباره «پرداخت» را می‌زند.

بدون idempotency، این یعنی تا سه بار شارژ برای یک پرداخت. با کلید idempotency (برای کال‌بک، شناسه تراکنش خود درگاه گزینه خوبی است)، تماس دوم و سوم اولی را پیدا می‌کنند و نتیجه‌اش را برمی‌گردانند. موجودی دقیقاً یک بار تغییر می‌کند.

کد قابل اجرا

یک minimal API با EF Core. با dotnet new web بسازید، بسته Microsoft.EntityFrameworkCore.Sqlite را اضافه کنید و Program.cs را جایگزین کنید:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<PaymentsDb>(options => options.UseSqlite("Data Source=payments.db"));

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    scope.ServiceProvider.GetRequiredService<PaymentsDb>().Database.EnsureCreated();
}

app.MapPost("/payments", async (
    [Microsoft.AspNetCore.Mvc.FromHeader(Name = "Idempotency-Key")] string? key,
    PaymentRequest body,
    PaymentsDb db,
    CancellationToken ct) =>
{
    if (string.IsNullOrWhiteSpace(key) || key.Length > 64)
    {
        return Results.BadRequest("Send an Idempotency-Key header (max 64 characters).");
    }

    // 1. Seen this key before? Return the original result instead of paying twice.
    var existing = await db.Payments.AsNoTracking().FirstOrDefaultAsync(p => p.IdempotencyKey == key, ct);
    if (existing is not null)
    {
        return Replay(existing, body);
    }

    // 2. First time: record the payment. The unique index on IdempotencyKey is the real guard.
    var payment = new Payment
    {
        IdempotencyKey = key,
        WalletId = body.WalletId,
        AmountRials = body.AmountRials,
        CreatedAt = DateTime.UtcNow,
    };
    db.Payments.Add(payment);

    try
    {
        await db.SaveChangesAsync(ct);
    }
    catch (DbUpdateException)
    {
        // 3. Two identical requests raced past step 1; the database let exactly one in.
        db.ChangeTracker.Clear();
        var winner = await db.Payments.AsNoTracking().FirstAsync(p => p.IdempotencyKey == key, ct);
        return Replay(winner, body);
    }

    return Results.Ok(payment);
});

app.Run();

// Same key + same request → same answer. Same key + different request → a client bug worth surfacing.
static IResult Replay(Payment original, PaymentRequest body) =>
    original.WalletId == body.WalletId && original.AmountRials == body.AmountRials
        ? Results.Ok(original)
        : Results.Conflict("This Idempotency-Key was already used for a different request.");

public record PaymentRequest(Guid WalletId, long AmountRials);

public class Payment
{
    public int Id { get; set; }
    public required string IdempotencyKey { get; set; }
    public Guid WalletId { get; set; }
    public long AmountRials { get; set; }
    public DateTime CreatedAt { get; set; }
}

public class PaymentsDb(DbContextOptions<PaymentsDb> options) : DbContext(options)
{
    public DbSet<Payment> Payments => Set<Payment>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Payment>().HasIndex(p => p.IdempotencyKey).IsUnique();
        modelBuilder.Entity<Payment>().Property(p => p.IdempotencyKey).HasMaxLength(64);
    }
}

امتحانش کنید:

curl -X POST localhost:5000/payments -H "Idempotency-Key: abc-1" -H "Content-Type: application/json" \
     -d '{"walletId":"3fa85f64-5717-4562-b3fc-2c963f66afa6","amountRials":500000}'

دو بار اجرایش کنید؛ هر دو بار همان پرداخت برمی‌گردد. مبلغ را عوض کنید ولی کلید را نگه دارید؛ 409 Conflict می‌گیرید.

سه جزئیات بار اصلی را می‌کشند:

  • ایندکس یکتا، نه if. جست‌وجو در گام ۱ مسیر سریع است؛ ایندکس در گام ۳ تضمین است.
  • تکرار، درخواست را مقایسه می‌کند. استفاده دوباره از یک کلید برای مبلغ دیگر باگ کلاینت است و برگرداندن بی‌صدای پرداخت قبلی آن را پنهان می‌کند.
  • مبلغ‌ها عدد صحیح بر حسب ریال‌اند. هیچ عدد اعشاری نزدیک پول نیست.

کال‌بک درگاه: کلید از قبل وجود دارد

برای کال‌بک درگاه لازم نیست کلید بسازید؛ درگاه آن را می‌دهد. درگاه‌های ایرانی در هر کال‌بک و تأیید یک شناسه تراکنش (معمولاً RefId یا کد رهگیری) برمی‌گردانند. همان را کلید idempotency عملیات «شارژ کیف‌پول» کنید و ایندکس یکتا را رویش بگذارید. آن‌وقت درگاهی که دو بار کال‌بک می‌زند، یا تأییدی که بعد از timeout دوباره می‌فرستید، نمی‌تواند کیف‌پول را دو بار شارژ کند.

دو قاعده دیگر برای کال‌بک:

  • قبل از شارژ، تأیید کنید. کال‌بک فقط می‌گوید کاربر برگشته؛ فراخوانی verify درگاه می‌گوید پرداخت واقعاً موفق بوده و به چه مبلغی. همان مبلغی را شارژ کنید که درگاه تأیید کرده، نه مبلغ query string کال‌بک.
  • «قبلاً پردازش شده» را موفقیت حساب کنید. وقتی همان شناسه دوباره می‌رسد، نتیجه اصلی («پرداخت دریافت شد») را به کاربر نشان دهید، نه صفحه خطایی که او را به پرداخت دوباره بکشاند.

مزایا و معایب

مزایا

  • تلاش دوباره امن می‌شود؛ کلاینت و درگاه می‌توانند با خیال راحت بعد از timeout دوباره تلاش کنند.
  • قاعده در یک جا زندگی می‌کند (قید دیتابیس)، نه در همه فراخواننده‌ها.
  • تکرار ارزان است: یک جست‌وجوی ایندکس‌شده.

معایب

  • کلیدها باید ذخیره و در نهایت پاک شوند؛ مدت نگهداری را مشخص کنید.
  • این نمونه entity ذخیره‌شده را برمی‌گرداند، نه دقیقاً پاسخ HTTP اولیه را. یک API واقعی معمولاً status و بدنه پاسخ را هم کنار کلید ذخیره می‌کند.
  • کلاینت باید برای هر عملیات کلید بسازد و نگه دارد؛ قراردادی که باید مستند شود.

کجا لازم نیست

خواندن‌های خالص (GET) و نوشتن‌هایی که ذاتاً idempotent هستند (PUT یک منبع کامل) کلید نمی‌خواهند. هر چیزی که پول جابه‌جا می‌کند، پیام می‌فرستد یا رکورد می‌سازد، می‌خواهد.


یک جریان پرداخت، کیف‌پول یا هندلر کال‌بک لازم دارید که زیر تلاش‌های دوباره سالم بماند؟ ببینید چطور می‌توانم کمک کنم.

پرسش‌های متداول

بررسی idempotency در کد بدون ایندکس یکتا کافی است؟

نه. دو درخواست یکسان می‌توانند قبل از ذخیره هر کدام، هر دو از بررسی «این کلید را دیده‌ام؟» رد شوند. فقط یک قید یکتا در دیتابیس تضمین می‌کند دقیقاً یکی برنده شود.

کلیدهای idempotency را تا کی نگه داریم؟

دست‌کم تا وقتی کلاینت یا درگاه ممکن است دوباره تلاش کند. بسیاری از APIهای پرداخت آن‌ها را ۲۴ ساعت یا بیشتر نگه می‌دارند؛ برای کال‌بک درگاه، تا وقتی درگاه ممکن است دوباره بفرستد.

کلید idempotency را چه کسی می‌سازد؟

کلاینت، یک بار برای هر عملیات منطقی و قبل از اولین تلاش. همه تلاش‌های دوباره همان عملیات باید همان کلید را بفرستند؛ عملیات جدید کلید جدید می‌گیرد.