feat: integrate PYMS payment gateway, add blog/sitepage/image services, local file manager
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m44s
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m44s
Payment Gateway: - Add PYMSPaymentService: IPaymentGatewayService via gRPC to PYMS microservice - Add ZarinPalPaymentService: direct ZarinPal integration (backup) - Register 'pyms' payment provider in DI ConfigureServices - Add PYMS proto files (pyms_transaction.proto, pyms_public_messages.proto) - Fix VerifyDiscountWalletCharge: pass 'OK' as status instead of Authority - Update appsettings: PaymentProvider=pyms, sandbox mode, merchant ID Blog System: - Add BlogCategory, BlogPost, BlogPostImage entities and CQRS - Add proto files and gRPC services for blog management - Add Mapster profiles for blog responses Content Management: - Add SitePage entity and CQRS for static pages - Add proto and gRPC service for site pages Image/File Management: - Add LocalFileManager with disk storage + base64 serving + FMS fallback - Add ImagePathResolverInterceptor for gRPC responses - Add ImageResolverService for explicit image resolution - Add UploadsController for public file serving with FMS fallback - Add PaymentCallbackController for discount order payment callbacks Database: - Add blog and content entity migrations - Remove ImagePath MaxLength constraints - Remove old FileManagementService (replaced by LocalFileManager)
This commit is contained in:
+5
@@ -9,4 +9,9 @@ public class AddDiscountProductImageCommand : IRequest<long>
|
||||
public string ThumbnailPath { get; set; } = string.Empty;
|
||||
public string? Title { get; set; }
|
||||
public string? AltText { get; set; }
|
||||
|
||||
// Image file upload
|
||||
public byte[]? ImageFileBytes { get; set; }
|
||||
public string? ImageFileMime { get; set; }
|
||||
public string? ImageFileName { get; set; }
|
||||
}
|
||||
|
||||
+23
-3
@@ -1,3 +1,4 @@
|
||||
using CMSMicroservice.Application.Common.FileManager;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Entities.DiscountShop;
|
||||
using MediatR;
|
||||
@@ -8,10 +9,12 @@ namespace CMSMicroservice.Application.DiscountShopCQ.Commands.AddDiscountProduct
|
||||
public class AddDiscountProductImageCommandHandler : IRequestHandler<AddDiscountProductImageCommand, long>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly IFileManager _fileManager;
|
||||
|
||||
public AddDiscountProductImageCommandHandler(IApplicationDbContext context)
|
||||
public AddDiscountProductImageCommandHandler(IApplicationDbContext context, IFileManager fileManager)
|
||||
{
|
||||
_context = context;
|
||||
_fileManager = fileManager;
|
||||
}
|
||||
|
||||
public async Task<long> Handle(AddDiscountProductImageCommand request, CancellationToken cancellationToken)
|
||||
@@ -23,6 +26,23 @@ public class AddDiscountProductImageCommandHandler : IRequestHandler<AddDiscount
|
||||
if (!productExists)
|
||||
throw new InvalidOperationException($"DiscountProduct with Id {request.DiscountProductId} not found.");
|
||||
|
||||
var imagePath = request.ImagePath;
|
||||
var thumbnailPath = request.ThumbnailPath;
|
||||
|
||||
// آپلود تصویر (اگر فایل ارسال شده باشد)
|
||||
if (request.ImageFileBytes is { Length: > 0 })
|
||||
{
|
||||
var result = await _fileManager.UploadImageAsync(
|
||||
"Images/DiscountProducts/Gallery",
|
||||
request.ImageFileBytes,
|
||||
request.ImageFileMime ?? "image/jpeg",
|
||||
request.ImageFileName,
|
||||
cancellationToken);
|
||||
|
||||
imagePath = result.Main.Path;
|
||||
thumbnailPath = result.Thumbnail.Path;
|
||||
}
|
||||
|
||||
// Get the max sort order for this product
|
||||
var maxSortOrder = await _context.DiscountProductImages
|
||||
.Where(i => i.DiscountProductId == request.DiscountProductId)
|
||||
@@ -31,8 +51,8 @@ public class AddDiscountProductImageCommandHandler : IRequestHandler<AddDiscount
|
||||
var image = new DiscountProductImage
|
||||
{
|
||||
DiscountProductId = request.DiscountProductId,
|
||||
ImagePath = request.ImagePath,
|
||||
ThumbnailPath = request.ThumbnailPath,
|
||||
ImagePath = imagePath,
|
||||
ThumbnailPath = thumbnailPath,
|
||||
Title = request.Title,
|
||||
AltText = request.AltText,
|
||||
SortOrder = maxSortOrder + 1,
|
||||
|
||||
+10
@@ -11,5 +11,15 @@ public class CreateDiscountProductCommand : IRequest<long>
|
||||
public int MaxDiscountPercent { get; set; }
|
||||
public string ImagePath { get; set; }
|
||||
public string ThumbnailPath { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public List<long> CategoryIds { get; set; } = new();
|
||||
|
||||
// Image file upload
|
||||
public byte[]? ImageFileBytes { get; set; }
|
||||
public string? ImageFileMime { get; set; }
|
||||
public string? ImageFileName { get; set; }
|
||||
public byte[]? ThumbnailFileBytes { get; set; }
|
||||
public string? ThumbnailFileMime { get; set; }
|
||||
public string? ThumbnailFileName { get; set; }
|
||||
}
|
||||
|
||||
+36
-5
@@ -1,3 +1,4 @@
|
||||
using CMSMicroservice.Application.Common.FileManager;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Entities.DiscountShop;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
@@ -10,13 +11,16 @@ public class CreateDiscountProductCommandHandler : IRequestHandler<CreateDiscoun
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly IInventoryService _inventoryService;
|
||||
private readonly IFileManager _fileManager;
|
||||
|
||||
public CreateDiscountProductCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
IInventoryService inventoryService)
|
||||
IInventoryService inventoryService,
|
||||
IFileManager fileManager)
|
||||
{
|
||||
_context = context;
|
||||
_inventoryService = inventoryService;
|
||||
_fileManager = fileManager;
|
||||
}
|
||||
|
||||
public async Task<long> Handle(CreateDiscountProductCommand request, CancellationToken cancellationToken)
|
||||
@@ -28,15 +32,42 @@ public class CreateDiscountProductCommandHandler : IRequestHandler<CreateDiscoun
|
||||
FullInformation = request.FullInformation,
|
||||
Price = request.Price,
|
||||
MaxDiscountPercent = request.MaxDiscountPercent,
|
||||
ImagePath = request.ImagePath,
|
||||
ThumbnailPath = request.ThumbnailPath,
|
||||
RemainingCount = 0, // موجودی اولیه صفر - باید از طریق Inventory اضافه شود
|
||||
ImagePath = request.ImagePath ?? string.Empty,
|
||||
ThumbnailPath = request.ThumbnailPath ?? string.Empty,
|
||||
RemainingCount = 0,
|
||||
Rate = 0,
|
||||
SaleCount = 0,
|
||||
ViewCount = 0,
|
||||
IsActive = true
|
||||
IsActive = request.IsActive
|
||||
};
|
||||
|
||||
// آپلود تصویر اصلی (اگر فایل ارسال شده باشد)
|
||||
if (request.ImageFileBytes is { Length: > 0 })
|
||||
{
|
||||
var result = await _fileManager.UploadImageAsync(
|
||||
"Images/DiscountProducts",
|
||||
request.ImageFileBytes,
|
||||
request.ImageFileMime ?? "image/jpeg",
|
||||
request.ImageFileName,
|
||||
cancellationToken);
|
||||
|
||||
product.ImagePath = result.Main.Path;
|
||||
product.ThumbnailPath = result.Thumbnail.Path;
|
||||
}
|
||||
|
||||
// آپلود بندانگشتی جداگانه (اختیاری)
|
||||
if (request.ThumbnailFileBytes is { Length: > 0 })
|
||||
{
|
||||
var thumbResult = await _fileManager.UploadAsync(
|
||||
"Images/DiscountProducts/Thumbnails",
|
||||
request.ThumbnailFileBytes,
|
||||
request.ThumbnailFileMime ?? "image/jpeg",
|
||||
request.ThumbnailFileName,
|
||||
cancellationToken);
|
||||
|
||||
product.ThumbnailPath = thumbResult.Path;
|
||||
}
|
||||
|
||||
_context.DiscountProducts.Add(product);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
|
||||
+5
@@ -18,4 +18,9 @@ public class PlaceOrderResponseDto
|
||||
public long TotalAmount { get; set; }
|
||||
public long DiscountBalanceUsed { get; set; }
|
||||
public long GatewayAmountRequired { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// URL درگاه پرداخت — اگر null باشد یعنی نیاز به پرداخت آنلاین نیست
|
||||
/// </summary>
|
||||
public string? PaymentUrl { get; set; }
|
||||
}
|
||||
|
||||
+101
-3
@@ -5,6 +5,8 @@ using CMSMicroservice.Domain.Entities.Payment;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.Application.DiscountShopCQ.Commands.PlaceOrder;
|
||||
|
||||
@@ -12,13 +14,22 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly IInventoryService _inventoryService;
|
||||
private readonly IPaymentGatewayService _paymentGateway;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<PlaceOrderCommandHandler> _logger;
|
||||
|
||||
public PlaceOrderCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
IInventoryService inventoryService)
|
||||
IInventoryService inventoryService,
|
||||
IPaymentGatewayService paymentGateway,
|
||||
IConfiguration configuration,
|
||||
ILogger<PlaceOrderCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_inventoryService = inventoryService;
|
||||
_paymentGateway = paymentGateway;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PlaceOrderResponseDto> Handle(PlaceOrderCommand request, CancellationToken cancellationToken)
|
||||
@@ -172,15 +183,102 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// اگر مبلغ درگاه > ۰ باشد، باید به درگاه پرداخت متصل شویم
|
||||
string? paymentUrl = null;
|
||||
|
||||
if (finalGatewayAmount > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
// آدرس callback — زرینپال بعد از پرداخت کاربر را به اینجا هدایت میکند
|
||||
var cmsBaseUrl = _configuration["CmsBaseUrl"] ?? "https://localhost:32846";
|
||||
var callbackUrl = $"{cmsBaseUrl}/api/payment/discount-order/callback?orderId={order.Id}";
|
||||
|
||||
// درخواست به درگاه
|
||||
var paymentResult = await _paymentGateway.InitiatePaymentAsync(new PaymentRequest
|
||||
{
|
||||
Amount = finalGatewayAmount,
|
||||
UserId = request.UserId,
|
||||
Description = $"فروشگاه تخفیفی - سفارش #{order.Id}",
|
||||
CallbackUrl = callbackUrl
|
||||
}, cancellationToken);
|
||||
|
||||
if (paymentResult.IsSuccess && !string.IsNullOrEmpty(paymentResult.GatewayUrl))
|
||||
{
|
||||
// ذخیره Authority/RefId در تراکنش برای verify بعدی
|
||||
transaction.RefId = paymentResult.RefId;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
paymentUrl = paymentResult.GatewayUrl;
|
||||
_logger.LogInformation(
|
||||
"Payment gateway initiated for DiscountOrder #{OrderId}: RefId={RefId}, Url={Url}",
|
||||
order.Id, paymentResult.RefId, paymentResult.GatewayUrl);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
"Payment gateway initiation failed for DiscountOrder #{OrderId}: {Error}",
|
||||
order.Id, paymentResult.ErrorMessage);
|
||||
|
||||
return new PlaceOrderResponseDto
|
||||
{
|
||||
Success = false,
|
||||
Message = $"خطا در اتصال به درگاه پرداخت: {paymentResult.ErrorMessage}",
|
||||
OrderId = order.Id
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Payment gateway exception for DiscountOrder #{OrderId}", order.Id);
|
||||
return new PlaceOrderResponseDto
|
||||
{
|
||||
Success = false,
|
||||
Message = $"خطا در اتصال به درگاه پرداخت: {ex.Message}",
|
||||
OrderId = order.Id
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// اگر کل مبلغ از کیف تخفیفی پرداخت شد — مستقیماً تکمیل شود
|
||||
transaction.PaymentStatus = PaymentStatus.Success;
|
||||
transaction.PaymentDate = DateTime.Now;
|
||||
order.PaymentStatus = PaymentStatus.Success;
|
||||
order.PaymentDate = DateTime.Now;
|
||||
order.DeliveryStatus = DeliveryStatus.InTransit;
|
||||
|
||||
var walletForDeduct = await _context.UserWallets
|
||||
.FirstOrDefaultAsync(w => w.UserId == request.UserId, cancellationToken);
|
||||
if (walletForDeduct != null)
|
||||
walletForDeduct.DiscountBalance -= actualDiscountBalanceUsed;
|
||||
|
||||
foreach (var cartItem in cartItems)
|
||||
{
|
||||
await _inventoryService.ConfirmSaleAsync(
|
||||
cartItem.ProductId, ProductType.DiscountProduct,
|
||||
cartItem.Count, order.Id, cancellationToken);
|
||||
cartItem.Product.SaleCount += cartItem.Count;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
_logger.LogInformation(
|
||||
"DiscountOrder #{OrderId} fully paid via discount balance ({Amount} T)",
|
||||
order.Id, actualDiscountBalanceUsed);
|
||||
}
|
||||
|
||||
return new PlaceOrderResponseDto
|
||||
{
|
||||
Success = true,
|
||||
Message = "سفارش ایجاد شد. لطفا پرداخت را تکمیل کنید",
|
||||
Message = finalGatewayAmount > 0
|
||||
? "سفارش ایجاد شد. در حال انتقال به درگاه پرداخت..."
|
||||
: "سفارش با موفقیت ثبت و پرداخت شد",
|
||||
OrderId = order.Id,
|
||||
TransactionId = transaction.Id,
|
||||
TotalAmount = totalAmount,
|
||||
DiscountBalanceUsed = actualDiscountBalanceUsed,
|
||||
GatewayAmountRequired = finalGatewayAmount
|
||||
GatewayAmountRequired = finalGatewayAmount,
|
||||
PaymentUrl = paymentUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -12,7 +12,16 @@ public class UpdateDiscountProductCommand : IRequest<Unit>
|
||||
public int MaxDiscountPercent { get; set; }
|
||||
public string ImagePath { get; set; }
|
||||
public string ThumbnailPath { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public int RemainingCount { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public List<long> CategoryIds { get; set; } = new();
|
||||
|
||||
// Image file upload
|
||||
public byte[]? ImageFileBytes { get; set; }
|
||||
public string? ImageFileMime { get; set; }
|
||||
public string? ImageFileName { get; set; }
|
||||
public byte[]? ThumbnailFileBytes { get; set; }
|
||||
public string? ThumbnailFileMime { get; set; }
|
||||
public string? ThumbnailFileName { get; set; }
|
||||
}
|
||||
|
||||
+39
-3
@@ -1,3 +1,4 @@
|
||||
using CMSMicroservice.Application.Common.FileManager;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Entities.DiscountShop;
|
||||
using MediatR;
|
||||
@@ -8,10 +9,12 @@ namespace CMSMicroservice.Application.DiscountShopCQ.Commands.UpdateDiscountProd
|
||||
public class UpdateDiscountProductCommandHandler : IRequestHandler<UpdateDiscountProductCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly IFileManager _fileManager;
|
||||
|
||||
public UpdateDiscountProductCommandHandler(IApplicationDbContext context)
|
||||
public UpdateDiscountProductCommandHandler(IApplicationDbContext context, IFileManager fileManager)
|
||||
{
|
||||
_context = context;
|
||||
_fileManager = fileManager;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(UpdateDiscountProductCommand request, CancellationToken cancellationToken)
|
||||
@@ -27,11 +30,44 @@ public class UpdateDiscountProductCommandHandler : IRequestHandler<UpdateDiscoun
|
||||
product.FullInformation = request.FullInformation;
|
||||
product.Price = request.Price;
|
||||
product.MaxDiscountPercent = request.MaxDiscountPercent;
|
||||
product.ImagePath = request.ImagePath;
|
||||
product.ThumbnailPath = request.ThumbnailPath;
|
||||
product.RemainingCount = request.RemainingCount;
|
||||
product.IsActive = request.IsActive;
|
||||
|
||||
// آپلود تصویر اصلی (اگر فایل جدید ارسال شده باشد)
|
||||
if (request.ImageFileBytes is { Length: > 0 })
|
||||
{
|
||||
var result = await _fileManager.UploadImageAsync(
|
||||
"Images/DiscountProducts",
|
||||
request.ImageFileBytes,
|
||||
request.ImageFileMime ?? "image/jpeg",
|
||||
request.ImageFileName,
|
||||
cancellationToken);
|
||||
|
||||
product.ImagePath = result.Main.Path;
|
||||
product.ThumbnailPath = result.Thumbnail.Path;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(request.ImagePath))
|
||||
{
|
||||
product.ImagePath = request.ImagePath;
|
||||
}
|
||||
|
||||
// آپلود بندانگشتی جداگانه (اختیاری)
|
||||
if (request.ThumbnailFileBytes is { Length: > 0 })
|
||||
{
|
||||
var thumbResult = await _fileManager.UploadAsync(
|
||||
"Images/DiscountProducts/Thumbnails",
|
||||
request.ThumbnailFileBytes,
|
||||
request.ThumbnailFileMime ?? "image/jpeg",
|
||||
request.ThumbnailFileName,
|
||||
cancellationToken);
|
||||
|
||||
product.ThumbnailPath = thumbResult.Path;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(request.ThumbnailPath))
|
||||
{
|
||||
product.ThumbnailPath = request.ThumbnailPath;
|
||||
}
|
||||
|
||||
// Update categories
|
||||
var existingCategories = await _context.DiscountProductCategories
|
||||
.Where(pc => pc.ProductId == request.ProductId)
|
||||
|
||||
+2
@@ -43,4 +43,6 @@ public class OrderItemDto
|
||||
public int DiscountPercentUsed { get; set; }
|
||||
public long DiscountAmount { get; set; }
|
||||
public long FinalPrice { get; set; }
|
||||
public string ImagePath { get; set; }
|
||||
public string ThumbnailPath { get; set; }
|
||||
}
|
||||
|
||||
+3
-1
@@ -53,7 +53,9 @@ public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, Order
|
||||
UnitPrice = od.UnitPrice,
|
||||
DiscountPercentUsed = od.DiscountPercentUsed,
|
||||
DiscountAmount = od.DiscountAmount,
|
||||
FinalPrice = od.FinalPrice
|
||||
FinalPrice = od.FinalPrice,
|
||||
ImagePath = od.Product.ImagePath ?? "",
|
||||
ThumbnailPath = od.Product.ThumbnailPath ?? ""
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user