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
@@ -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<CreateNewPackageResponse> CreateNewPackage(CreateNewPackageRequest request, ServerCallContext context)
{
@@ -138,12 +143,31 @@ public class PackageService : PackageContract.PackageContractBase
public override async Task<CustomerPurchasePackageResponse> 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<CustomerPurchasePackageResponse> 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<CustomerVerifyPackagePurchaseResponse> CustomerVerifyPackagePurchaseCore(
CustomerVerifyPackagePurchaseRequest request,
CancellationToken ct)
{
var purchase = await _context.UserPackagePurchases
.Include(p => p.Package)
.Where(p => p.Id == request.OrderId && !p.IsDeleted)