feat(payment): add per-user in-memory lock for gateway operations
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 9m58s

Introduce IUserPaymentLock to serialize payment initiate and verify flows
per user, preventing concurrent duplicate gateway requests across services.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
masoodafar-web
2026-06-08 01:55:00 +03:30
parent f08d3ac78f
commit d22eb1617f
14 changed files with 543 additions and 66 deletions
@@ -0,0 +1,22 @@
namespace CMSMicroservice.Application.Common.Exceptions;
/// <summary>
/// Thrown when a concurrent payment operation is already in progress for the same scope.
/// </summary>
public class PaymentInProgressException : Exception
{
public PaymentInProgressException()
: base("یک عملیات پرداخت دیگر در حال پردازش است. لطفاً چند لحظه صبر کنید.")
{
}
public PaymentInProgressException(string message)
: base(message)
{
}
public PaymentInProgressException(string message, Exception innerException)
: base(message, innerException)
{
}
}
@@ -0,0 +1,32 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// Per-scope in-process mutex for payment flows (initiate / verify).
/// Prevents the same user from running duplicate gateway operations concurrently.
/// </summary>
public interface IUserPaymentLock
{
Task<T> ExecuteAsync<T>(
string scope,
PaymentLockStrategy strategy,
Func<CancellationToken, Task<T>> action,
CancellationToken cancellationToken = default);
Task ExecuteAsync(
string scope,
PaymentLockStrategy strategy,
Func<CancellationToken, Task> action,
CancellationToken cancellationToken = default);
}
/// <summary>
/// How to behave when the payment lock is already held.
/// </summary>
public enum PaymentLockStrategy
{
/// <summary>Wait up to the configured timeout (callback / verify paths).</summary>
WaitForRelease,
/// <summary>Reject immediately (initiate / double-click paths).</summary>
FailFast
}
@@ -0,0 +1,19 @@
namespace CMSMicroservice.Application.Common;
/// <summary>
/// Canonical scope keys for <see cref="Interfaces.IUserPaymentLock"/>.
/// </summary>
public static class PaymentLockScopes
{
/// <summary>One active payment initiation per user (package, wallet charge, order, IPG deposit).</summary>
public static string Initiate(long userId) => $"payment:initiate:user:{userId}";
/// <summary>One active verify per user + authority/order (duplicate callback protection).</summary>
public static string Verify(long userId, string resourceKey)
{
if (string.IsNullOrWhiteSpace(resourceKey))
throw new ArgumentException("Payment verify resource key is required.", nameof(resourceKey));
return $"payment:verify:user:{userId}:{resourceKey.Trim()}";
}
}
@@ -1,3 +1,5 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Services;
using CMSMicroservice.Domain.Entities.DiscountShop;
@@ -17,22 +19,32 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
private readonly IPaymentGatewayService _paymentGateway;
private readonly IConfiguration _configuration;
private readonly ILogger<PlaceOrderCommandHandler> _logger;
private readonly IUserPaymentLock _paymentLock;
public PlaceOrderCommandHandler(
IApplicationDbContext context,
IInventoryService inventoryService,
IPaymentGatewayService paymentGateway,
IConfiguration configuration,
ILogger<PlaceOrderCommandHandler> logger)
ILogger<PlaceOrderCommandHandler> logger,
IUserPaymentLock paymentLock)
{
_context = context;
_inventoryService = inventoryService;
_paymentGateway = paymentGateway;
_configuration = configuration;
_logger = logger;
_paymentLock = paymentLock;
}
public async Task<PlaceOrderResponseDto> Handle(PlaceOrderCommand request, CancellationToken cancellationToken)
public Task<PlaceOrderResponseDto> Handle(PlaceOrderCommand request, CancellationToken cancellationToken) =>
_paymentLock.ExecuteAsync(
PaymentLockScopes.Initiate(request.UserId),
PaymentLockStrategy.FailFast,
ct => HandleCore(request, ct),
cancellationToken);
private async Task<PlaceOrderResponseDto> HandleCore(PlaceOrderCommand request, CancellationToken cancellationToken)
{
// Get user wallet
var userWallet = await _context.UserWallets
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models;
@@ -17,20 +18,32 @@ public class ChargeDiscountWalletCommandHandler
private readonly IPaymentGatewayService _paymentGateway;
private readonly IConfiguration _configuration;
private readonly ILogger<ChargeDiscountWalletCommandHandler> _logger;
private readonly IUserPaymentLock _paymentLock;
public ChargeDiscountWalletCommandHandler(
IApplicationDbContext context,
IPaymentGatewayService paymentGateway,
IConfiguration configuration,
ILogger<ChargeDiscountWalletCommandHandler> logger)
ILogger<ChargeDiscountWalletCommandHandler> logger,
IUserPaymentLock paymentLock)
{
_context = context;
_paymentGateway = paymentGateway;
_configuration = configuration;
_logger = logger;
_paymentLock = paymentLock;
}
public async Task<PaymentInitiateResult> Handle(
public Task<PaymentInitiateResult> Handle(
ChargeDiscountWalletCommand request,
CancellationToken cancellationToken) =>
_paymentLock.ExecuteAsync(
PaymentLockScopes.Initiate(request.UserId),
PaymentLockStrategy.FailFast,
ct => HandleCore(request, ct),
cancellationToken);
private async Task<PaymentInitiateResult> HandleCore(
ChargeDiscountWalletCommand request,
CancellationToken cancellationToken)
{
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Common;
@@ -18,20 +19,32 @@ public class ChargeMagicWalletCommandHandler
private readonly IPaymentGatewayService _paymentGateway;
private readonly IConfiguration _configuration;
private readonly ILogger<ChargeMagicWalletCommandHandler> _logger;
private readonly IUserPaymentLock _paymentLock;
public ChargeMagicWalletCommandHandler(
IApplicationDbContext context,
IPaymentGatewayService paymentGateway,
IConfiguration configuration,
ILogger<ChargeMagicWalletCommandHandler> logger)
ILogger<ChargeMagicWalletCommandHandler> logger,
IUserPaymentLock paymentLock)
{
_context = context;
_paymentGateway = paymentGateway;
_configuration = configuration;
_logger = logger;
_paymentLock = paymentLock;
}
public async Task<PaymentInitiateResult> Handle(
public Task<PaymentInitiateResult> Handle(
ChargeMagicWalletCommand request,
CancellationToken cancellationToken) =>
_paymentLock.ExecuteAsync(
PaymentLockScopes.Initiate(request.UserId),
PaymentLockStrategy.FailFast,
ct => HandleCore(request, ct),
cancellationToken);
private async Task<PaymentInitiateResult> HandleCore(
ChargeMagicWalletCommand request,
CancellationToken cancellationToken)
{
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models;
@@ -15,18 +16,30 @@ public class VerifyDiscountWalletChargeCommandHandler
private readonly IApplicationDbContext _context;
private readonly IPaymentGatewayService _paymentGateway;
private readonly ILogger<VerifyDiscountWalletChargeCommandHandler> _logger;
private readonly IUserPaymentLock _paymentLock;
public VerifyDiscountWalletChargeCommandHandler(
IApplicationDbContext context,
IPaymentGatewayService paymentGateway,
ILogger<VerifyDiscountWalletChargeCommandHandler> logger)
ILogger<VerifyDiscountWalletChargeCommandHandler> logger,
IUserPaymentLock paymentLock)
{
_context = context;
_paymentGateway = paymentGateway;
_logger = logger;
_paymentLock = paymentLock;
}
public async Task<bool> Handle(
public Task<bool> Handle(
VerifyDiscountWalletChargeCommand request,
CancellationToken cancellationToken) =>
_paymentLock.ExecuteAsync(
PaymentLockScopes.Verify(request.UserId, request.Authority),
PaymentLockStrategy.WaitForRelease,
ct => HandleCore(request, ct),
cancellationToken);
private async Task<bool> HandleCore(
VerifyDiscountWalletChargeCommand request,
CancellationToken cancellationToken)
{
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Common;
@@ -16,20 +17,44 @@ public class VerifyMagicWalletChargeCommandHandler
private readonly IApplicationDbContext _context;
private readonly IPaymentGatewayService _paymentGateway;
private readonly ILogger<VerifyMagicWalletChargeCommandHandler> _logger;
private readonly IUserPaymentLock _paymentLock;
public VerifyMagicWalletChargeCommandHandler(
IApplicationDbContext context,
IPaymentGatewayService paymentGateway,
ILogger<VerifyMagicWalletChargeCommandHandler> logger)
ILogger<VerifyMagicWalletChargeCommandHandler> logger,
IUserPaymentLock paymentLock)
{
_context = context;
_paymentGateway = paymentGateway;
_logger = logger;
_paymentLock = paymentLock;
}
public async Task<bool> Handle(
VerifyMagicWalletChargeCommand request,
CancellationToken cancellationToken)
{
var paymentTx = await _context.PaymentTransactions
.AsNoTracking()
.FirstOrDefaultAsync(pt => pt.Authority == request.Authority, cancellationToken);
if (paymentTx == null)
throw new NotFoundException("تراکنش پرداخت یافت نشد");
if (!paymentTx.UserId.HasValue)
throw new BadRequestException("شناسه کاربر در تراکنش پرداخت یافت نشد");
return await _paymentLock.ExecuteAsync(
PaymentLockScopes.Verify(paymentTx.UserId.Value, request.Authority),
PaymentLockStrategy.WaitForRelease,
ct => HandleCore(request, ct),
cancellationToken);
}
private async Task<bool> HandleCore(
VerifyMagicWalletChargeCommand request,
CancellationToken cancellationToken)
{
try
{