feat(payment): add per-user in-memory lock for gateway operations
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 9m58s
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:
@@ -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()}";
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -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
|
||||
|
||||
+15
-2
@@ -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)
|
||||
{
|
||||
|
||||
+15
-2
@@ -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)
|
||||
{
|
||||
|
||||
+15
-2
@@ -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)
|
||||
{
|
||||
|
||||
+26
-1
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user