From d22eb1617fe0714241df3654976887377fbf5d2b Mon Sep 17 00:00:00 2001 From: masoodafar-web Date: Mon, 8 Jun 2026 01:55:00 +0330 Subject: [PATCH] feat(payment): add per-user in-memory lock for gateway operations Introduce IUserPaymentLock to serialize payment initiate and verify flows per user, preventing concurrent duplicate gateway requests across services. Co-authored-by: Cursor --- .../Exceptions/PaymentInProgressException.cs | 22 +++ .../Common/Interfaces/IUserPaymentLock.cs | 32 ++++ .../Common/PaymentLockScopes.cs | 19 +++ .../PlaceOrder/PlaceOrderCommandHandler.cs | 16 +- .../ChargeDiscountWalletCommandHandler.cs | 17 ++- .../ChargeMagicWalletCommandHandler.cs | 17 ++- ...erifyDiscountWalletChargeCommandHandler.cs | 17 ++- .../VerifyMagicWalletChargeCommandHandler.cs | 27 +++- .../ConfigureServices.cs | 1 + .../Services/UserPaymentLockService.cs | 143 ++++++++++++++++++ .../Services/DiscountOrderService.cs | 95 +++++++++--- .../Services/PackageService.cs | 72 +++++++-- .../Services/TransactionsService.cs | 72 +++++++-- .../Services/UserWalletService.cs | 59 +++++--- 14 files changed, 543 insertions(+), 66 deletions(-) create mode 100644 src/CMSMicroservice.Application/Common/Exceptions/PaymentInProgressException.cs create mode 100644 src/CMSMicroservice.Application/Common/Interfaces/IUserPaymentLock.cs create mode 100644 src/CMSMicroservice.Application/Common/PaymentLockScopes.cs create mode 100644 src/CMSMicroservice.Infrastructure/Services/UserPaymentLockService.cs diff --git a/src/CMSMicroservice.Application/Common/Exceptions/PaymentInProgressException.cs b/src/CMSMicroservice.Application/Common/Exceptions/PaymentInProgressException.cs new file mode 100644 index 0000000..631a6ad --- /dev/null +++ b/src/CMSMicroservice.Application/Common/Exceptions/PaymentInProgressException.cs @@ -0,0 +1,22 @@ +namespace CMSMicroservice.Application.Common.Exceptions; + +/// +/// Thrown when a concurrent payment operation is already in progress for the same scope. +/// +public class PaymentInProgressException : Exception +{ + public PaymentInProgressException() + : base("یک عملیات پرداخت دیگر در حال پردازش است. لطفاً چند لحظه صبر کنید.") + { + } + + public PaymentInProgressException(string message) + : base(message) + { + } + + public PaymentInProgressException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/CMSMicroservice.Application/Common/Interfaces/IUserPaymentLock.cs b/src/CMSMicroservice.Application/Common/Interfaces/IUserPaymentLock.cs new file mode 100644 index 0000000..aa2c9c2 --- /dev/null +++ b/src/CMSMicroservice.Application/Common/Interfaces/IUserPaymentLock.cs @@ -0,0 +1,32 @@ +namespace CMSMicroservice.Application.Common.Interfaces; + +/// +/// Per-scope in-process mutex for payment flows (initiate / verify). +/// Prevents the same user from running duplicate gateway operations concurrently. +/// +public interface IUserPaymentLock +{ + Task ExecuteAsync( + string scope, + PaymentLockStrategy strategy, + Func> action, + CancellationToken cancellationToken = default); + + Task ExecuteAsync( + string scope, + PaymentLockStrategy strategy, + Func action, + CancellationToken cancellationToken = default); +} + +/// +/// How to behave when the payment lock is already held. +/// +public enum PaymentLockStrategy +{ + /// Wait up to the configured timeout (callback / verify paths). + WaitForRelease, + + /// Reject immediately (initiate / double-click paths). + FailFast +} diff --git a/src/CMSMicroservice.Application/Common/PaymentLockScopes.cs b/src/CMSMicroservice.Application/Common/PaymentLockScopes.cs new file mode 100644 index 0000000..3fb78e7 --- /dev/null +++ b/src/CMSMicroservice.Application/Common/PaymentLockScopes.cs @@ -0,0 +1,19 @@ +namespace CMSMicroservice.Application.Common; + +/// +/// Canonical scope keys for . +/// +public static class PaymentLockScopes +{ + /// One active payment initiation per user (package, wallet charge, order, IPG deposit). + public static string Initiate(long userId) => $"payment:initiate:user:{userId}"; + + /// One active verify per user + authority/order (duplicate callback protection). + 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()}"; + } +} diff --git a/src/CMSMicroservice.Application/DiscountShopCQ/Commands/PlaceOrder/PlaceOrderCommandHandler.cs b/src/CMSMicroservice.Application/DiscountShopCQ/Commands/PlaceOrder/PlaceOrderCommandHandler.cs index 35fa80e..37690a1 100644 --- a/src/CMSMicroservice.Application/DiscountShopCQ/Commands/PlaceOrder/PlaceOrderCommandHandler.cs +++ b/src/CMSMicroservice.Application/DiscountShopCQ/Commands/PlaceOrder/PlaceOrderCommandHandler.cs @@ -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 _logger; + private readonly IUserPaymentLock _paymentLock; public PlaceOrderCommandHandler( IApplicationDbContext context, IInventoryService inventoryService, IPaymentGatewayService paymentGateway, IConfiguration configuration, - ILogger logger) + ILogger logger, + IUserPaymentLock paymentLock) { _context = context; _inventoryService = inventoryService; _paymentGateway = paymentGateway; _configuration = configuration; _logger = logger; + _paymentLock = paymentLock; } - public async Task Handle(PlaceOrderCommand request, CancellationToken cancellationToken) + public Task Handle(PlaceOrderCommand request, CancellationToken cancellationToken) => + _paymentLock.ExecuteAsync( + PaymentLockScopes.Initiate(request.UserId), + PaymentLockStrategy.FailFast, + ct => HandleCore(request, ct), + cancellationToken); + + private async Task HandleCore(PlaceOrderCommand request, CancellationToken cancellationToken) { // Get user wallet var userWallet = await _context.UserWallets diff --git a/src/CMSMicroservice.Application/WalletCQ/Commands/ChargeDiscountWallet/ChargeDiscountWalletCommandHandler.cs b/src/CMSMicroservice.Application/WalletCQ/Commands/ChargeDiscountWallet/ChargeDiscountWalletCommandHandler.cs index 411d570..d38a076 100644 --- a/src/CMSMicroservice.Application/WalletCQ/Commands/ChargeDiscountWallet/ChargeDiscountWalletCommandHandler.cs +++ b/src/CMSMicroservice.Application/WalletCQ/Commands/ChargeDiscountWallet/ChargeDiscountWalletCommandHandler.cs @@ -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 _logger; + private readonly IUserPaymentLock _paymentLock; public ChargeDiscountWalletCommandHandler( IApplicationDbContext context, IPaymentGatewayService paymentGateway, IConfiguration configuration, - ILogger logger) + ILogger logger, + IUserPaymentLock paymentLock) { _context = context; _paymentGateway = paymentGateway; _configuration = configuration; _logger = logger; + _paymentLock = paymentLock; } - public async Task Handle( + public Task Handle( + ChargeDiscountWalletCommand request, + CancellationToken cancellationToken) => + _paymentLock.ExecuteAsync( + PaymentLockScopes.Initiate(request.UserId), + PaymentLockStrategy.FailFast, + ct => HandleCore(request, ct), + cancellationToken); + + private async Task HandleCore( ChargeDiscountWalletCommand request, CancellationToken cancellationToken) { diff --git a/src/CMSMicroservice.Application/WalletCQ/Commands/ChargeMagicWallet/ChargeMagicWalletCommandHandler.cs b/src/CMSMicroservice.Application/WalletCQ/Commands/ChargeMagicWallet/ChargeMagicWalletCommandHandler.cs index fd9ece1..18f328e 100644 --- a/src/CMSMicroservice.Application/WalletCQ/Commands/ChargeMagicWallet/ChargeMagicWalletCommandHandler.cs +++ b/src/CMSMicroservice.Application/WalletCQ/Commands/ChargeMagicWallet/ChargeMagicWalletCommandHandler.cs @@ -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 _logger; + private readonly IUserPaymentLock _paymentLock; public ChargeMagicWalletCommandHandler( IApplicationDbContext context, IPaymentGatewayService paymentGateway, IConfiguration configuration, - ILogger logger) + ILogger logger, + IUserPaymentLock paymentLock) { _context = context; _paymentGateway = paymentGateway; _configuration = configuration; _logger = logger; + _paymentLock = paymentLock; } - public async Task Handle( + public Task Handle( + ChargeMagicWalletCommand request, + CancellationToken cancellationToken) => + _paymentLock.ExecuteAsync( + PaymentLockScopes.Initiate(request.UserId), + PaymentLockStrategy.FailFast, + ct => HandleCore(request, ct), + cancellationToken); + + private async Task HandleCore( ChargeMagicWalletCommand request, CancellationToken cancellationToken) { diff --git a/src/CMSMicroservice.Application/WalletCQ/Commands/VerifyDiscountWalletCharge/VerifyDiscountWalletChargeCommandHandler.cs b/src/CMSMicroservice.Application/WalletCQ/Commands/VerifyDiscountWalletCharge/VerifyDiscountWalletChargeCommandHandler.cs index a5694d5..ec45fc2 100644 --- a/src/CMSMicroservice.Application/WalletCQ/Commands/VerifyDiscountWalletCharge/VerifyDiscountWalletChargeCommandHandler.cs +++ b/src/CMSMicroservice.Application/WalletCQ/Commands/VerifyDiscountWalletCharge/VerifyDiscountWalletChargeCommandHandler.cs @@ -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 _logger; + private readonly IUserPaymentLock _paymentLock; public VerifyDiscountWalletChargeCommandHandler( IApplicationDbContext context, IPaymentGatewayService paymentGateway, - ILogger logger) + ILogger logger, + IUserPaymentLock paymentLock) { _context = context; _paymentGateway = paymentGateway; _logger = logger; + _paymentLock = paymentLock; } - public async Task Handle( + public Task Handle( + VerifyDiscountWalletChargeCommand request, + CancellationToken cancellationToken) => + _paymentLock.ExecuteAsync( + PaymentLockScopes.Verify(request.UserId, request.Authority), + PaymentLockStrategy.WaitForRelease, + ct => HandleCore(request, ct), + cancellationToken); + + private async Task HandleCore( VerifyDiscountWalletChargeCommand request, CancellationToken cancellationToken) { diff --git a/src/CMSMicroservice.Application/WalletCQ/Commands/VerifyMagicWalletCharge/VerifyMagicWalletChargeCommandHandler.cs b/src/CMSMicroservice.Application/WalletCQ/Commands/VerifyMagicWalletCharge/VerifyMagicWalletChargeCommandHandler.cs index c455e4e..f90e7b2 100644 --- a/src/CMSMicroservice.Application/WalletCQ/Commands/VerifyMagicWalletCharge/VerifyMagicWalletChargeCommandHandler.cs +++ b/src/CMSMicroservice.Application/WalletCQ/Commands/VerifyMagicWalletCharge/VerifyMagicWalletChargeCommandHandler.cs @@ -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 _logger; + private readonly IUserPaymentLock _paymentLock; public VerifyMagicWalletChargeCommandHandler( IApplicationDbContext context, IPaymentGatewayService paymentGateway, - ILogger logger) + ILogger logger, + IUserPaymentLock paymentLock) { _context = context; _paymentGateway = paymentGateway; _logger = logger; + _paymentLock = paymentLock; } public async Task 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 HandleCore( + VerifyMagicWalletChargeCommand request, + CancellationToken cancellationToken) { try { diff --git a/src/CMSMicroservice.Infrastructure/ConfigureServices.cs b/src/CMSMicroservice.Infrastructure/ConfigureServices.cs index 3c96121..8890d70 100644 --- a/src/CMSMicroservice.Infrastructure/ConfigureServices.cs +++ b/src/CMSMicroservice.Infrastructure/ConfigureServices.cs @@ -39,6 +39,7 @@ public static class ConfigureServices services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); // Local file manager — files are saved to wwwroot/uploads/ on CMS disk services.AddSingleton(); services.AddScoped(); diff --git a/src/CMSMicroservice.Infrastructure/Services/UserPaymentLockService.cs b/src/CMSMicroservice.Infrastructure/Services/UserPaymentLockService.cs new file mode 100644 index 0000000..9137915 --- /dev/null +++ b/src/CMSMicroservice.Infrastructure/Services/UserPaymentLockService.cs @@ -0,0 +1,143 @@ +using System.Collections.Concurrent; +using CMSMicroservice.Application.Common.Exceptions; +using CMSMicroservice.Application.Common.Interfaces; +using Microsoft.Extensions.Logging; + +namespace CMSMicroservice.Infrastructure.Services; + +/// +/// In-process keyed semaphore lock for payment operations. +/// Safe for multi-threaded gRPC/MediatR handlers within a single CMS instance. +/// +public sealed class UserPaymentLockService : IUserPaymentLock, IDisposable +{ + private static readonly TimeSpan VerifyWaitTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan StaleEntryAge = TimeSpan.FromMinutes(30); + private static readonly TimeSpan CleanupInterval = TimeSpan.FromMinutes(5); + + private readonly ConcurrentDictionary _entries = new(); + private readonly ILogger _logger; + private readonly Timer _cleanupTimer; + private int _disposed; + + public UserPaymentLockService(ILogger logger) + { + _logger = logger; + _cleanupTimer = new Timer(_ => CleanupStaleEntries(), null, CleanupInterval, CleanupInterval); + } + + public Task ExecuteAsync( + string scope, + PaymentLockStrategy strategy, + Func> action, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scope); + ArgumentNullException.ThrowIfNull(action); + + return ExecuteCoreAsync(scope, strategy, action, cancellationToken); + } + + public async Task ExecuteAsync( + string scope, + PaymentLockStrategy strategy, + Func action, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scope); + ArgumentNullException.ThrowIfNull(action); + + await ExecuteCoreAsync( + scope, + strategy, + async ct => + { + await action(ct); + return true; + }, + cancellationToken); + } + + private async Task ExecuteCoreAsync( + string scope, + PaymentLockStrategy strategy, + Func> action, + CancellationToken cancellationToken) + { + var entry = _entries.GetOrAdd(scope, static _ => new LockEntry()); + entry.Touch(); + + var waitTimeout = strategy == PaymentLockStrategy.FailFast + ? TimeSpan.Zero + : VerifyWaitTimeout; + + Interlocked.Increment(ref entry.WaiterCount); + + var acquired = false; + try + { + acquired = await entry.Semaphore.WaitAsync(waitTimeout, cancellationToken); + if (!acquired) + { + _logger.LogWarning( + "Payment lock busy. Scope={Scope}, Strategy={Strategy}", + scope, + strategy); + + throw new PaymentInProgressException( + strategy == PaymentLockStrategy.FailFast + ? "درخواست پرداخت دیگری در حال پردازش است. لطفاً صبر کنید." + : "تأیید پرداخت در حال انجام است. لطفاً چند لحظه صبر کنید."); + } + + entry.Touch(); + return await action(cancellationToken); + } + finally + { + if (acquired) + entry.Semaphore.Release(); + + if (Interlocked.Decrement(ref entry.WaiterCount) == 0 && entry.CanRemove) + _entries.TryRemove(scope, out _); + } + } + + private void CleanupStaleEntries() + { + if (Interlocked.CompareExchange(ref _disposed, 0, 0) == 1) + return; + + var cutoff = DateTime.UtcNow - StaleEntryAge; + foreach (var (scope, entry) in _entries) + { + if (entry.LastUsedUtc < cutoff && entry.CanRemove) + _entries.TryRemove(scope, out _); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) + return; + + _cleanupTimer.Dispose(); + + foreach (var entry in _entries.Values) + entry.Semaphore.Dispose(); + + _entries.Clear(); + } + + private sealed class LockEntry + { + public SemaphoreSlim Semaphore { get; } = new(1, 1); + public int WaiterCount; + public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow; + + public void Touch() => LastUsedUtc = DateTime.UtcNow; + + public bool CanRemove => + Volatile.Read(ref WaiterCount) == 0 && Semaphore.CurrentCount == 1; + } +} diff --git a/src/CMSMicroservice.WebApi/Services/DiscountOrderService.cs b/src/CMSMicroservice.WebApi/Services/DiscountOrderService.cs index f5967cd..a209882 100644 --- a/src/CMSMicroservice.WebApi/Services/DiscountOrderService.cs +++ b/src/CMSMicroservice.WebApi/Services/DiscountOrderService.cs @@ -1,5 +1,7 @@ using CMSMicroservice.Protobuf.Protos.DiscountOrder; using CMSMicroservice.WebApi.Common.Services; +using CMSMicroservice.Application.Common; +using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.DiscountShopCQ.Commands.PlaceOrder; using CMSMicroservice.Application.DiscountShopCQ.Commands.CompleteOrderPayment; @@ -26,6 +28,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB private readonly IApplicationDbContext _context; private readonly IPaymentGatewayService _paymentGateway; private readonly ILogger _logger; + private readonly IUserPaymentLock _paymentLock; public DiscountOrderService( IDispatchRequestToCQRS dispatchRequestToCQRS, @@ -33,7 +36,8 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB ICurrentUserService currentUserService, IApplicationDbContext context, IPaymentGatewayService paymentGateway, - ILogger logger) + ILogger logger, + IUserPaymentLock paymentLock) { _dispatchRequestToCQRS = dispatchRequestToCQRS; _sender = sender; @@ -41,6 +45,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB _context = context; _paymentGateway = paymentGateway; _logger = logger; + _paymentLock = paymentLock; } private long GetCurrentUserId() @@ -58,20 +63,32 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB UserAddressId = request.UserAddressId, DiscountBalanceToUse = request.DiscountBalanceToUse }; - var result = await _sender.Send(command); - - var response = new PlaceOrderResponse + + try { - Success = result.Success, - Message = result.Message ?? string.Empty, - OrderId = result.OrderId ?? 0, - GatewayAmount = result.GatewayAmountRequired, - }; + var result = await _sender.Send(command); - if (!string.IsNullOrEmpty(result.PaymentUrl)) - response.PaymentUrl = result.PaymentUrl; + var response = new PlaceOrderResponse + { + Success = result.Success, + Message = result.Message ?? string.Empty, + OrderId = result.OrderId ?? 0, + GatewayAmount = result.GatewayAmountRequired, + }; - return response; + if (!string.IsNullOrEmpty(result.PaymentUrl)) + response.PaymentUrl = result.PaymentUrl; + + return response; + } + catch (PaymentInProgressException ex) + { + return new PlaceOrderResponse + { + Success = false, + Message = ex.Message + }; + } } public override async Task CompleteOrderPayment(CompleteOrderPaymentRequest request, ServerCallContext context) @@ -193,6 +210,48 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB public override async Task CustomerVerifyDiscountOrderPayment( CustomerVerifyDiscountOrderPaymentRequest request, ServerCallContext context) + { + var orderOwner = await _context.DiscountOrders + .Where(o => o.Id == request.OrderId) + .Select(o => (long?)o.UserId) + .FirstOrDefaultAsync(context.CancellationToken); + + if (orderOwner is null or 0) + { + return new CustomerVerifyDiscountOrderPaymentResponse + { + Success = false, + Message = "سفارش یافت نشد", + OrderId = request.OrderId + }; + } + + var verifyKey = !string.IsNullOrEmpty(request.Authority) + ? request.Authority + : request.OrderId.ToString(); + + try + { + return await _paymentLock.ExecuteAsync( + PaymentLockScopes.Verify(orderOwner.Value, verifyKey), + PaymentLockStrategy.WaitForRelease, + ct => CustomerVerifyDiscountOrderPaymentCore(request, ct), + context.CancellationToken); + } + catch (PaymentInProgressException ex) + { + return new CustomerVerifyDiscountOrderPaymentResponse + { + Success = false, + Message = ex.Message, + OrderId = request.OrderId + }; + } + } + + private async Task CustomerVerifyDiscountOrderPaymentCore( + CustomerVerifyDiscountOrderPaymentRequest request, + CancellationToken cancellationToken) { _logger.LogInformation( "CustomerVerifyDiscountOrderPayment called: OrderId={OrderId}, Authority={Authority}, Status={Status}", @@ -203,7 +262,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB // پیدا کردن سفارش و تراکنش var order = await _context.DiscountOrders .Include(o => o.OrderDetails) - .FirstOrDefaultAsync(o => o.Id == request.OrderId, context.CancellationToken); + .FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken); if (order == null) { @@ -218,7 +277,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB var transaction = order.TransactionId.HasValue ? await _context.Transactions.FirstOrDefaultAsync( - t => t.Id == order.TransactionId.Value, context.CancellationToken) + t => t.Id == order.TransactionId.Value, cancellationToken) : null; // تأیید پرداخت از درگاه @@ -233,14 +292,14 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB request.Authority, request.Status, order.GatewayAmountPaid, - context.CancellationToken); + cancellationToken); paymentSuccess = verifyResult.IsSuccess; refId = verifyResult.TrackingCode ?? verifyResult.RefId; // آپدیت PaymentTransaction با نتیجه verify var paymentTx = await _context.PaymentTransactions - .FirstOrDefaultAsync(pt => pt.Authority == request.Authority, context.CancellationToken); + .FirstOrDefaultAsync(pt => pt.Authority == request.Authority, cancellationToken); if (paymentTx != null) { paymentTx.PaymentStatus = verifyResult.IsSuccess; @@ -249,7 +308,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB paymentTx.CardPan = verifyResult.CardPan; paymentTx.CardHash = verifyResult.CardHash; paymentTx.RefId = verifyResult.TrackingCode; - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); } _logger.LogInformation( @@ -268,7 +327,7 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB TransactionId = transaction?.Id ?? 0, PaymentSuccess = paymentSuccess, RefId = refId - }, context.CancellationToken); + }, cancellationToken); return new CustomerVerifyDiscountOrderPaymentResponse { diff --git a/src/CMSMicroservice.WebApi/Services/PackageService.cs b/src/CMSMicroservice.WebApi/Services/PackageService.cs index 45ce117..1b8bf84 100644 --- a/src/CMSMicroservice.WebApi/Services/PackageService.cs +++ b/src/CMSMicroservice.WebApi/Services/PackageService.cs @@ -10,6 +10,8 @@ using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPackages; using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPackageDetails; using CMSMicroservice.Application.PackageCQ.Queries.GetCustomerPurchaseHistory; using CMSMicroservice.Application.Common.Interfaces; +using CMSMicroservice.Application.Common; +using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Domain.Entities.Payment; using AppModels = CMSMicroservice.Application.Common.Models; using Grpc.Core; @@ -32,6 +34,7 @@ public class PackageService : PackageContract.PackageContractBase private readonly ICurrentUserService _currentUserService; private readonly IPaymentGatewayService _paymentGateway; private readonly IConfiguration _configuration; + private readonly IUserPaymentLock _paymentLock; public PackageService( IDispatchRequestToCQRS dispatchRequestToCQRS, @@ -39,7 +42,8 @@ public class PackageService : PackageContract.PackageContractBase IApplicationDbContext context, ICurrentUserService currentUserService, IPaymentGatewayService paymentGateway, - IConfiguration configuration) + IConfiguration configuration, + IUserPaymentLock paymentLock) { _dispatchRequestToCQRS = dispatchRequestToCQRS; _sender = sender; @@ -47,6 +51,7 @@ public class PackageService : PackageContract.PackageContractBase _currentUserService = currentUserService; _paymentGateway = paymentGateway; _configuration = configuration; + _paymentLock = paymentLock; } public override async Task CreateNewPackage(CreateNewPackageRequest request, ServerCallContext context) { @@ -138,12 +143,31 @@ public class PackageService : PackageContract.PackageContractBase public override async Task CustomerPurchasePackage(CustomerPurchasePackageRequest request, ServerCallContext context) { var userId = GetCurrentUserId(); - + + try + { + return await _paymentLock.ExecuteAsync( + PaymentLockScopes.Initiate(userId), + PaymentLockStrategy.FailFast, + ct => CustomerPurchasePackageCore(request, userId, ct), + context.CancellationToken); + } + catch (PaymentInProgressException ex) + { + throw new RpcException(new Status(StatusCode.FailedPrecondition, ex.Message)); + } + } + + private async Task CustomerPurchasePackageCore( + CustomerPurchasePackageRequest request, + long userId, + CancellationToken cancellationToken) + { // Lookup package var package = await _context.Packages .AsNoTracking() .Where(p => p.Id == request.PackageId && !p.IsDeleted) - .FirstOrDefaultAsync(context.CancellationToken); + .FirstOrDefaultAsync(cancellationToken); if (package == null) throw new RpcException(new Status(StatusCode.NotFound, "پکیج مورد نظر یافت نشد")); @@ -157,7 +181,7 @@ public class PackageService : PackageContract.PackageContractBase Type = Domain.Enums.TransactionType.Buy }; _context.Transactions.Add(transaction); - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); // Create purchase record var purchaseMethod = request.PurchaseMethod == PurchaseMethodEnum.PurchaseMethodGateway @@ -174,14 +198,14 @@ public class PackageService : PackageContract.PackageContractBase TransactionId = transaction.Id }; _context.UserPackagePurchases.Add(purchase); - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); // Initiate payment with gateway var user = await _context.Users .AsNoTracking() .Where(u => u.Id == userId) .Select(u => new { u.Mobile }) - .FirstOrDefaultAsync(context.CancellationToken); + .FirstOrDefaultAsync(cancellationToken); // Callback URL از config — نه از ورودی کاربر (امنیت) var frontOfficeBaseUrl = _configuration["FrontOfficeBaseUrl"] ?? "https://localhost:5268"; @@ -194,12 +218,12 @@ public class PackageService : PackageContract.PackageContractBase Mobile = user?.Mobile ?? string.Empty, Description = $"خرید پکیج {package.Title}", CallbackUrl = callbackUrl - }, context.CancellationToken); + }, cancellationToken); if (!paymentResult.IsSuccess) { transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); return new CustomerPurchasePackageResponse { @@ -229,7 +253,7 @@ public class PackageService : PackageContract.PackageContractBase OrderId = purchase.Id.ToString() }; _context.PaymentTransactions.Add(paymentTx); - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); return new CustomerPurchasePackageResponse { @@ -245,6 +269,36 @@ public class PackageService : PackageContract.PackageContractBase { var ct = context.CancellationToken; + var purchaseUserId = await _context.UserPackagePurchases + .Where(p => p.Id == request.OrderId && !p.IsDeleted) + .Select(p => (long?)p.UserId) + .FirstOrDefaultAsync(ct); + + if (purchaseUserId is null or 0) + throw new RpcException(new Status(StatusCode.NotFound, "سفارش یافت نشد")); + + var verifyKey = !string.IsNullOrEmpty(request.Authority) + ? request.Authority + : request.OrderId.ToString(); + + try + { + return await _paymentLock.ExecuteAsync( + PaymentLockScopes.Verify(purchaseUserId.Value, verifyKey), + PaymentLockStrategy.WaitForRelease, + lockCt => CustomerVerifyPackagePurchaseCore(request, lockCt), + ct); + } + catch (PaymentInProgressException ex) + { + throw new RpcException(new Status(StatusCode.FailedPrecondition, ex.Message)); + } + } + + private async Task CustomerVerifyPackagePurchaseCore( + CustomerVerifyPackagePurchaseRequest request, + CancellationToken ct) + { var purchase = await _context.UserPackagePurchases .Include(p => p.Package) .Where(p => p.Id == request.OrderId && !p.IsDeleted) diff --git a/src/CMSMicroservice.WebApi/Services/TransactionsService.cs b/src/CMSMicroservice.WebApi/Services/TransactionsService.cs index dfd2d25..c88b48b 100644 --- a/src/CMSMicroservice.WebApi/Services/TransactionsService.cs +++ b/src/CMSMicroservice.WebApi/Services/TransactionsService.cs @@ -10,6 +10,8 @@ using CMSMicroservice.Application.TransactionsCQ.Commands.RefundTransaction; using CMSMicroservice.Application.TransactionsCQ.Queries.GetCustomerTransaction; using CMSMicroservice.Application.TransactionsCQ.Queries.GetCustomerTransactionsByFilter; using CMSMicroservice.Application.Common.Interfaces; +using CMSMicroservice.Application.Common; +using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Domain.Entities.Payment; using AppModels = CMSMicroservice.Application.Common.Models; using Grpc.Core; @@ -28,6 +30,7 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase private readonly ICurrentUserService _currentUserService; private readonly IPaymentGatewayService _paymentGateway; private readonly IConfiguration _configuration; + private readonly IUserPaymentLock _paymentLock; public TransactionsService( IDispatchRequestToCQRS dispatchRequestToCQRS, @@ -35,7 +38,8 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase IApplicationDbContext context, ICurrentUserService currentUserService, IPaymentGatewayService paymentGateway, - IConfiguration configuration) + IConfiguration configuration, + IUserPaymentLock paymentLock) { _dispatchRequestToCQRS = dispatchRequestToCQRS; _sender = sender; @@ -43,6 +47,7 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase _currentUserService = currentUserService; _paymentGateway = paymentGateway; _configuration = configuration; + _paymentLock = paymentLock; } public override async Task CreateNewTransactions(CreateNewTransactionsRequest request, ServerCallContext context) { @@ -142,13 +147,32 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase public override async Task CustomerPaymentRequest(CustomerPaymentRequestRequest request, ServerCallContext context) { var userId = GetCurrentUserId(); - + + try + { + return await _paymentLock.ExecuteAsync( + PaymentLockScopes.Initiate(userId), + PaymentLockStrategy.FailFast, + ct => CustomerPaymentRequestCore(request, userId, ct), + context.CancellationToken); + } + catch (PaymentInProgressException ex) + { + throw new RpcException(new Status(StatusCode.FailedPrecondition, ex.Message)); + } + } + + private async Task CustomerPaymentRequestCore( + CustomerPaymentRequestRequest request, + long userId, + CancellationToken cancellationToken) + { // Get user mobile for payment gateway var user = await _context.Users .AsNoTracking() .Where(u => u.Id == userId) .Select(u => new { u.Mobile, u.Email }) - .FirstOrDefaultAsync(context.CancellationToken); + .FirstOrDefaultAsync(cancellationToken); // Create transaction record in DB var transaction = new CMSMicroservice.Domain.Entities.Transaction @@ -160,7 +184,7 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase }; _context.Transactions.Add(transaction); - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); // Callback URL از config — نه از ورودی کاربر (امنیت) var frontOfficeBaseUrl = _configuration["FrontOfficeBaseUrl"] ?? "https://localhost:5268"; @@ -174,13 +198,13 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase Mobile = request.Mobile ?? user?.Mobile ?? string.Empty, Description = request.Description ?? "پرداخت آنلاین", CallbackUrl = callbackUrl - }, context.CancellationToken); + }, cancellationToken); if (!paymentResult.IsSuccess) { // Update transaction status to failed transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); throw new RpcException(new Status(StatusCode.Internal, paymentResult.ErrorMessage ?? "خطا در ارتباط با درگاه پرداخت")); @@ -206,7 +230,7 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase TransactionId = transaction.Id }; _context.PaymentTransactions.Add(paymentTx); - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); return new CustomerPaymentRequestResponse { @@ -215,11 +239,35 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase } public override async Task CustomerPaymentVerification(CustomerPaymentVerificationRequest request, ServerCallContext context) + { + var ct = context.CancellationToken; + var userId = GetCurrentUserId(); + var verifyKey = !string.IsNullOrEmpty(request.Authority) + ? request.Authority + : userId.ToString(); + + try + { + return await _paymentLock.ExecuteAsync( + PaymentLockScopes.Verify(userId, verifyKey), + PaymentLockStrategy.WaitForRelease, + lockCt => CustomerPaymentVerificationCore(request, lockCt), + ct); + } + catch (PaymentInProgressException ex) + { + throw new RpcException(new Status(StatusCode.FailedPrecondition, ex.Message)); + } + } + + private async Task CustomerPaymentVerificationCore( + CustomerPaymentVerificationRequest request, + CancellationToken cancellationToken) { // Find the transaction by authority/refId var transaction = await _context.Transactions .Where(t => t.RefId == request.Authority && !t.IsDeleted) - .FirstOrDefaultAsync(context.CancellationToken); + .FirstOrDefaultAsync(cancellationToken); if (transaction == null) throw new RpcException(new Status(StatusCode.NotFound, "تراکنش یافت نشد")); @@ -228,7 +276,7 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase if (request.Status != "OK") { transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); return new CustomerPaymentVerificationResponse { @@ -241,13 +289,13 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase // واکشی PaymentTransaction برای گرفتن مبلغ (تومان) جهت verify var paymentTx = await _context.PaymentTransactions - .FirstOrDefaultAsync(pt => pt.Authority == request.Authority, context.CancellationToken); + .FirstOrDefaultAsync(pt => pt.Authority == request.Authority, cancellationToken); var amountInToman = paymentTx?.Amount ?? transaction.Amount; // Verify with gateway (مبلغ به تومان — سرویس زرین‌پال خودش ×۱۰ می‌کنه) var verifyResult = await _paymentGateway.VerifyPaymentAsync( - request.Authority, request.Status, (decimal)amountInToman, context.CancellationToken); + request.Authority, request.Status, (decimal)amountInToman, cancellationToken); if (paymentTx != null) { paymentTx.PaymentStatus = verifyResult.IsSuccess; @@ -269,7 +317,7 @@ public class TransactionsService : TransactionsContract.TransactionsContractBase transaction.PaymentStatus = Domain.Enums.PaymentStatus.Reject; } - await _context.SaveChangesAsync(context.CancellationToken); + await _context.SaveChangesAsync(cancellationToken); return new CustomerPaymentVerificationResponse { diff --git a/src/CMSMicroservice.WebApi/Services/UserWalletService.cs b/src/CMSMicroservice.WebApi/Services/UserWalletService.cs index 3f0e015..3863aa5 100644 --- a/src/CMSMicroservice.WebApi/Services/UserWalletService.cs +++ b/src/CMSMicroservice.WebApi/Services/UserWalletService.cs @@ -13,6 +13,7 @@ using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWalletHistory; using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawals; using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawalSettings; using CMSMicroservice.Application.Common.Interfaces; +using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Domain.Common; using CMSMicroservice.Domain.Enums; using Grpc.Core; @@ -184,18 +185,29 @@ public class UserWalletService : UserWalletContract.UserWalletContractBase { var userId = GetCurrentUserId(); - var result = await _sender.Send(new ChargeMagicWalletCommand + try { - UserId = userId, - Amount = request.Amount - }, context.CancellationToken); + var result = await _sender.Send(new ChargeMagicWalletCommand + { + UserId = userId, + Amount = request.Amount + }, context.CancellationToken); - return new InitiateMagicChargeResponse + return new InitiateMagicChargeResponse + { + IsSuccess = result.IsSuccess, + GatewayUrl = result.GatewayUrl ?? "", + ErrorMessage = result.ErrorMessage ?? "" + }; + } + catch (PaymentInProgressException ex) { - IsSuccess = result.IsSuccess, - GatewayUrl = result.GatewayUrl ?? "", - ErrorMessage = result.ErrorMessage ?? "" - }; + return new InitiateMagicChargeResponse + { + IsSuccess = false, + ErrorMessage = ex.Message + }; + } } // ============= Discount Wallet Methods ============= @@ -205,18 +217,29 @@ public class UserWalletService : UserWalletContract.UserWalletContractBase { var userId = GetCurrentUserId(); - var result = await _sender.Send(new ChargeDiscountWalletCommand + try { - UserId = userId, - Amount = request.Amount - }, context.CancellationToken); + var result = await _sender.Send(new ChargeDiscountWalletCommand + { + UserId = userId, + Amount = request.Amount + }, context.CancellationToken); - return new InitiateDiscountChargeResponse + return new InitiateDiscountChargeResponse + { + IsSuccess = result.IsSuccess, + GatewayUrl = result.GatewayUrl ?? "", + ErrorMessage = result.ErrorMessage ?? "" + }; + } + catch (PaymentInProgressException ex) { - IsSuccess = result.IsSuccess, - GatewayUrl = result.GatewayUrl ?? "", - ErrorMessage = result.ErrorMessage ?? "" - }; + return new InitiateDiscountChargeResponse + { + IsSuccess = false, + ErrorMessage = ex.Message + }; + } } // ============= Wallet Verify Methods =============