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:
@@ -1,13 +1,20 @@
|
||||
using Google.Protobuf;
|
||||
using Grpc.Core.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Common.Behaviours;
|
||||
|
||||
public class LoggingBehaviour : Interceptor
|
||||
public partial class LoggingBehaviour : Interceptor
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
|
||||
// فیلدهایی که نباید لاگ شوند (بایتهای تصویر / فایل)
|
||||
[GeneratedRegex(@"""(File|ImageFile|image_file|file)"":\s*\{[^}]*\}", RegexOptions.Singleline)]
|
||||
private static partial Regex BinaryFieldPattern();
|
||||
|
||||
public LoggingBehaviour(ILogger<LoggingBehaviour> logger, ICurrentUserService currentUserService)
|
||||
{
|
||||
_logger = logger;
|
||||
@@ -21,8 +28,11 @@ public class LoggingBehaviour : Interceptor
|
||||
{
|
||||
var requestName = typeof(TRequest).Name;
|
||||
var userId = _currentUserService.UserId ?? string.Empty;
|
||||
_logger.LogInformation("gRPC Starting receiving call. Type/Method: {Type} / {Method} Request: {Name} {@UserId} {@Request}",
|
||||
MethodType.Unary, context.Method , requestName, userId, request);
|
||||
|
||||
// لاگ بدون بایتهای فایل
|
||||
var safeLog = SanitizeForLog(request);
|
||||
_logger.LogInformation("gRPC Starting receiving call. Type/Method: {Type} / {Method} Request: {Name} {UserId} {Request}",
|
||||
MethodType.Unary, context.Method, requestName, userId, safeLog);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -30,9 +40,26 @@ public class LoggingBehaviour : Interceptor
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "gRPC Request: Unhandled Exception for Request {Name} {@Request}", requestName, request);
|
||||
|
||||
_logger.LogError(ex, "gRPC Request: Unhandled Exception for Request {Name} {Request}", requestName, safeLog);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// حذف بایتهای فایل از لاگ — جایگزینی با [BINARY DATA]
|
||||
/// </summary>
|
||||
private static string SanitizeForLog<T>(T request)
|
||||
{
|
||||
if (request is IMessage protoMessage)
|
||||
{
|
||||
var json = JsonFormatter.Default.Format(protoMessage);
|
||||
// حذف محتوای فیلدهای باینری
|
||||
json = BinaryFieldPattern().Replace(json, "\"$1\": \"[BINARY DATA]\"");
|
||||
// اگر هنوز رشتههای base64 طولانی هست، خلاصه کن
|
||||
if (json.Length > 2000)
|
||||
return json[..2000] + "... [TRUNCATED]";
|
||||
return json;
|
||||
}
|
||||
return request?.ToString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
using Google.Protobuf;
|
||||
using Grpc.Core.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Common.Behaviours;
|
||||
|
||||
public class PerformanceBehaviour : Interceptor
|
||||
public partial class PerformanceBehaviour : Interceptor
|
||||
{
|
||||
private readonly Stopwatch _timer;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
|
||||
[GeneratedRegex(@"""(File|ImageFile|image_file|file)"":\s*\{[^}]*\}", RegexOptions.Singleline)]
|
||||
private static partial Regex BinaryFieldPattern();
|
||||
|
||||
public PerformanceBehaviour(ILogger<PerformanceBehaviour> logger, ICurrentUserService currentUserService)
|
||||
{
|
||||
_timer = new Stopwatch();
|
||||
@@ -34,11 +40,25 @@ public class PerformanceBehaviour : Interceptor
|
||||
{
|
||||
var requestName = typeof(TRequest).Name;
|
||||
var userId = _currentUserService.UserId ?? string.Empty;
|
||||
var safeLog = SanitizeForLog(request);
|
||||
|
||||
_logger.LogWarning("gRPC Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {@UserId} {@Request}",
|
||||
requestName, elapsedMilliseconds, userId, request);
|
||||
_logger.LogWarning("gRPC Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {UserId} {Request}",
|
||||
requestName, elapsedMilliseconds, userId, safeLog);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private static string SanitizeForLog<T>(T request)
|
||||
{
|
||||
if (request is IMessage protoMessage)
|
||||
{
|
||||
var json = JsonFormatter.Default.Format(protoMessage);
|
||||
json = BinaryFieldPattern().Replace(json, "\"$1\": \"[BINARY DATA]\"");
|
||||
if (json.Length > 2000)
|
||||
return json[..2000] + "... [TRUNCATED]";
|
||||
return json;
|
||||
}
|
||||
return request?.ToString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using Mapster;
|
||||
using ProtoBlogCategory = CMSMicroservice.Protobuf.Protos.BlogCategory;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Common.Mappings;
|
||||
|
||||
public class BlogCategoryProfile : IRegister
|
||||
{
|
||||
void IRegister.Register(TypeAdapterConfig config)
|
||||
{
|
||||
// CreateBlogCategory: long → CreateBlogCategoryResponse
|
||||
config.NewConfig<long, ProtoBlogCategory.CreateBlogCategoryResponse>()
|
||||
.Map(dest => dest.Id, src => src);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using CMSMicroservice.Application.BlogPostCQ.Commands.PublishBlogPost;
|
||||
using CMSMicroservice.Application.BlogPostCQ.Commands.ArchiveBlogPost;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Mapster;
|
||||
using ProtoBlogPost = CMSMicroservice.Protobuf.Protos.BlogPost;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Common.Mappings;
|
||||
|
||||
public class BlogPostProfile : IRegister
|
||||
{
|
||||
void IRegister.Register(TypeAdapterConfig config)
|
||||
{
|
||||
// PublishBlogPost: Command result → Proto response
|
||||
config.NewConfig<PublishBlogPostResult, ProtoBlogPost.PublishBlogPostResponse>()
|
||||
.Map(dest => dest.Success, src => src.Success)
|
||||
.Map(dest => dest.Message, src => src.Message)
|
||||
.Map(dest => dest.PublishedAt, src => src.PublishedAt.HasValue
|
||||
? Timestamp.FromDateTime(DateTime.SpecifyKind(src.PublishedAt.Value, DateTimeKind.Utc))
|
||||
: null);
|
||||
|
||||
// ArchiveBlogPost: Command result → Proto response
|
||||
config.NewConfig<ArchiveBlogPostResult, ProtoBlogPost.ArchiveBlogPostResponse>()
|
||||
.Map(dest => dest.Success, src => src.Success)
|
||||
.Map(dest => dest.Message, src => src.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.DiscountShopCQ.Commands.CompleteOrderPayment;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Callback endpoint for payment gateways (ZarinPal, etc.)
|
||||
/// درگاه پرداخت بعد از پرداخت (یا لغو) کاربر را به اینجا redirect میکند
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[AllowAnonymous] // کاربر از درگاه بانک برمیگردد — JWT ندارد
|
||||
[ApiExplorerSettings(GroupName = "cms")]
|
||||
public class PaymentCallbackController : ControllerBase
|
||||
{
|
||||
private readonly ISender _sender;
|
||||
private readonly IPaymentGatewayService _paymentGateway;
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<PaymentCallbackController> _logger;
|
||||
|
||||
public PaymentCallbackController(
|
||||
ISender sender,
|
||||
IPaymentGatewayService paymentGateway,
|
||||
IApplicationDbContext context,
|
||||
IConfiguration configuration,
|
||||
ILogger<PaymentCallbackController> logger)
|
||||
{
|
||||
_sender = sender;
|
||||
_paymentGateway = paymentGateway;
|
||||
_context = context;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Callback برای پرداخت سفارش فروشگاه تخفیفی
|
||||
/// زرینپال کاربر را با Authority و Status به این endpoint برمیگرداند
|
||||
/// </summary>
|
||||
[HttpGet("/api/payment/discount-order/callback")]
|
||||
public async Task<IActionResult> DiscountOrderCallback(
|
||||
[FromQuery] long orderId,
|
||||
[FromQuery(Name = "Authority")] string? authority,
|
||||
[FromQuery(Name = "Status")] string? status,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var frontOfficeBaseUrl = _configuration["FrontOfficeBaseUrl"] ?? "https://localhost:5268";
|
||||
|
||||
_logger.LogInformation(
|
||||
"Payment callback received: OrderId={OrderId}, Authority={Authority}, Status={Status}",
|
||||
orderId, authority, status);
|
||||
|
||||
try
|
||||
{
|
||||
// پیدا کردن سفارش و تراکنش
|
||||
var order = await _context.DiscountOrders
|
||||
.Include(o => o.OrderDetails)
|
||||
.FirstOrDefaultAsync(o => o.Id == orderId, cancellationToken);
|
||||
|
||||
if (order == null)
|
||||
{
|
||||
_logger.LogError("Payment callback: Order #{OrderId} not found", orderId);
|
||||
return Redirect($"{frontOfficeBaseUrl}/discount-store/orders?error=order-not-found");
|
||||
}
|
||||
|
||||
var transaction = order.TransactionId.HasValue
|
||||
? await _context.Transactions.FirstOrDefaultAsync(
|
||||
t => t.Id == order.TransactionId.Value, cancellationToken)
|
||||
: null;
|
||||
|
||||
// تأیید پرداخت از درگاه
|
||||
bool paymentSuccess = false;
|
||||
string? refId = null;
|
||||
|
||||
if (string.Equals(status, "OK", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrEmpty(authority))
|
||||
{
|
||||
// Verify با مبلغ از دیتابیس (تومان)
|
||||
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
|
||||
authority,
|
||||
status!,
|
||||
order.GatewayAmountPaid, // مبلغ به تومان
|
||||
cancellationToken);
|
||||
|
||||
paymentSuccess = verifyResult.IsSuccess;
|
||||
refId = verifyResult.TrackingCode ?? verifyResult.RefId;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Payment verification for Order #{OrderId}: Success={Success}, RefId={RefId}, Message={Message}",
|
||||
orderId, paymentSuccess, refId, verifyResult.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Payment cancelled by user for Order #{OrderId}", orderId);
|
||||
}
|
||||
|
||||
// تکمیل سفارش از طریق CQRS
|
||||
var completeResult = await _sender.Send(new CompleteOrderPaymentCommand
|
||||
{
|
||||
OrderId = orderId,
|
||||
TransactionId = transaction?.Id ?? 0,
|
||||
PaymentSuccess = paymentSuccess,
|
||||
RefId = refId
|
||||
}, cancellationToken);
|
||||
|
||||
// Redirect به FrontOffice
|
||||
if (paymentSuccess && completeResult.Success)
|
||||
{
|
||||
_logger.LogInformation("Payment completed successfully for Order #{OrderId}", orderId);
|
||||
return Redirect(
|
||||
$"{frontOfficeBaseUrl}/discount-store/order/{orderId}?payment=success");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Payment failed for Order #{OrderId}", orderId);
|
||||
return Redirect(
|
||||
$"{frontOfficeBaseUrl}/discount-store/order/{orderId}?payment=failed");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Payment callback error for Order #{OrderId}", orderId);
|
||||
return Redirect(
|
||||
$"{frontOfficeBaseUrl}/discount-store/order/{orderId}?payment=error");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// سرویس عمومی سرو تصاویر — فایلها مستقیماً از پوشه Uploads سرو میشوند.
|
||||
/// اگر فایل محلی وجود نداشت، از FMS قدیمی (dl.afrino.co) دانلود و کش میشود.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[ApiExplorerSettings(GroupName = "cms")]
|
||||
public class UploadsController : ControllerBase
|
||||
{
|
||||
private readonly string _uploadRoot;
|
||||
private readonly string _fmsBaseUrl;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<UploadsController> _logger;
|
||||
private readonly FileExtensionContentTypeProvider _contentTypeProvider = new();
|
||||
|
||||
// حداکثر طول مسیر مجاز (جلوگیری از path traversal)
|
||||
private const int MaxPathLength = 500;
|
||||
|
||||
public UploadsController(
|
||||
IConfiguration configuration,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<UploadsController> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
|
||||
_uploadRoot = configuration["FileStorage:UploadPath"]
|
||||
?? Path.Combine(AppContext.BaseDirectory, "Uploads");
|
||||
|
||||
_fmsBaseUrl = configuration["FMS:Address"]?.TrimEnd('/') ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// سرو عمومی فایل از پوشه Uploads.
|
||||
/// اگر فایل محلی وجود نداشت و FMS تنظیم شده باشد، از FMS دانلود و کش میشود.
|
||||
/// </summary>
|
||||
/// <param name="path">مسیر نسبی فایل (مثلاً blog/image.jpg)</param>
|
||||
[HttpGet("uploads/{**path}")]
|
||||
[ResponseCache(Duration = 86400, Location = ResponseCacheLocation.Any)] // کش مرورگر ۲۴ ساعت
|
||||
public async Task<IActionResult> GetFile(string path)
|
||||
{
|
||||
// ── اعتبارسنجی مسیر ──
|
||||
if (string.IsNullOrWhiteSpace(path) || path.Length > MaxPathLength)
|
||||
return BadRequest("مسیر نامعتبر");
|
||||
|
||||
// جلوگیری از path traversal
|
||||
if (path.Contains("..") || path.Contains('\\'))
|
||||
return BadRequest("مسیر نامعتبر");
|
||||
|
||||
var sanitizedPath = path.TrimStart('/');
|
||||
var fullPath = Path.GetFullPath(Path.Combine(_uploadRoot, sanitizedPath));
|
||||
|
||||
// اطمینان از اینکه مسیر درون _uploadRoot باقی میماند
|
||||
if (!fullPath.StartsWith(Path.GetFullPath(_uploadRoot), StringComparison.OrdinalIgnoreCase))
|
||||
return BadRequest("مسیر نامعتبر");
|
||||
|
||||
// ── سرو فایل محلی ──
|
||||
if (System.IO.File.Exists(fullPath))
|
||||
return ServeFile(fullPath);
|
||||
|
||||
// ── Fallback: دانلود از FMS قدیمی ──
|
||||
if (string.IsNullOrWhiteSpace(_fmsBaseUrl))
|
||||
{
|
||||
_logger.LogWarning("File not found locally and no FMS configured: {Path}", sanitizedPath);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var downloaded = await TryDownloadFromFmsAsync(sanitizedPath, fullPath);
|
||||
if (downloaded)
|
||||
{
|
||||
_logger.LogInformation("Downloaded and cached from FMS: {Path}", sanitizedPath);
|
||||
return ServeFile(fullPath);
|
||||
}
|
||||
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// سرو فایل با Content-Type مناسب
|
||||
// ────────────────────────────────────────────────────
|
||||
private IActionResult ServeFile(string fullPath)
|
||||
{
|
||||
if (!_contentTypeProvider.TryGetContentType(fullPath, out var contentType))
|
||||
contentType = "application/octet-stream";
|
||||
|
||||
var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
return File(stream, contentType, enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// دانلود از FMS قدیمی و ذخیره محلی
|
||||
// ────────────────────────────────────────────────────
|
||||
private async Task<bool> TryDownloadFromFmsAsync(string relativePath, string localPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fmsUrl = $"{_fmsBaseUrl}/{relativePath}";
|
||||
_logger.LogInformation("Attempting FMS download: {Url}", fmsUrl);
|
||||
|
||||
using var client = _httpClientFactory.CreateClient("FMS");
|
||||
using var response = await client.GetAsync(fmsUrl, HttpCompletionOption.ResponseHeadersRead);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("FMS returned {Status} for {Url}", response.StatusCode, fmsUrl);
|
||||
return false;
|
||||
}
|
||||
|
||||
// بررسی Content-Type — فقط فایلهای تصویری/مجاز
|
||||
var mediaType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
|
||||
if (!IsAllowedMediaType(mediaType))
|
||||
{
|
||||
_logger.LogWarning("FMS returned disallowed content type {Type} for {Url}", mediaType, fmsUrl);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ذخیره روی دیسک
|
||||
var directory = Path.GetDirectoryName(localPath)!;
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
await using var fileStream = new FileStream(localPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
await response.Content.CopyToAsync(fileStream);
|
||||
|
||||
_logger.LogInformation("Cached FMS file locally: {Path} ({Size} bytes)", relativePath, fileStream.Length);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to download from FMS: {Path}", relativePath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAllowedMediaType(string mediaType)
|
||||
{
|
||||
return mediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)
|
||||
|| mediaType is "application/pdf"
|
||||
or "application/octet-stream";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using CMSMicroservice.Application.Common.FileManager;
|
||||
using Google.Protobuf;
|
||||
using Google.Protobuf.Reflection;
|
||||
using Grpc.Core;
|
||||
using Grpc.Core.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Interceptors;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC Interceptor — بعد از اجرای هر سرویس، فیلدهای تصویری response را
|
||||
/// از مسیر نسبی دیسک به base64 data-URI تبدیل میکند
|
||||
/// </summary>
|
||||
public class ImagePathResolverInterceptor : Interceptor
|
||||
{
|
||||
private readonly IFileManager _fileManager;
|
||||
private readonly ILogger<ImagePathResolverInterceptor> _logger;
|
||||
|
||||
// نام فیلدهایی که مسیر تصویر هستند
|
||||
private static readonly HashSet<string> ImageFieldNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"image_path",
|
||||
"thumbnail_path",
|
||||
"image_thumbnail_path",
|
||||
"featured_image_path",
|
||||
"featured_image_thumbnail_path",
|
||||
"hero_image_path",
|
||||
"product_thumbnail_path",
|
||||
"avatar_path",
|
||||
"avatar_url",
|
||||
"avatar"
|
||||
};
|
||||
|
||||
public ImagePathResolverInterceptor(IFileManager fileManager, ILogger<ImagePathResolverInterceptor> logger)
|
||||
{
|
||||
_fileManager = fileManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ── Unary call (اکثر gRPCها) ──
|
||||
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
ServerCallContext context,
|
||||
UnaryServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
var response = await continuation(request, context);
|
||||
|
||||
if (response is IMessage message)
|
||||
{
|
||||
ResolveImagePaths(message);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// ── Server streaming ──
|
||||
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
IServerStreamWriter<TResponse> responseStream,
|
||||
ServerCallContext context,
|
||||
ServerStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
var wrappedStream = new ImageResolvingStreamWriter<TResponse>(responseStream, this);
|
||||
await continuation(request, wrappedStream, context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// بازگشتی: تمام فیلدهای string با نام تصویری را resolve میکند
|
||||
/// شامل فیلدهای تکراری (repeated) و زیر-پیامها (sub-messages)
|
||||
/// </summary>
|
||||
internal void ResolveImagePaths(IMessage message)
|
||||
{
|
||||
var descriptor = message.Descriptor;
|
||||
|
||||
foreach (var field in descriptor.Fields.InFieldNumberOrder())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (field.FieldType == FieldType.String && ImageFieldNames.Contains(field.Name))
|
||||
{
|
||||
// فیلد string ساده
|
||||
var accessor = field.Accessor;
|
||||
var value = accessor.GetValue(message) as string;
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
var resolved = _fileManager.ResolveImageUrl(value);
|
||||
accessor.SetValue(message, resolved);
|
||||
}
|
||||
}
|
||||
else if (field.FieldType == FieldType.Message)
|
||||
{
|
||||
if (field.IsRepeated)
|
||||
{
|
||||
// repeated sub-message
|
||||
var list = field.Accessor.GetValue(message) as System.Collections.IList;
|
||||
if (list != null)
|
||||
{
|
||||
foreach (var item in list)
|
||||
{
|
||||
if (item is IMessage subMsg)
|
||||
ResolveImagePaths(subMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// فیلد oneof یا فیلد optional message
|
||||
var subMessage = field.Accessor.GetValue(message) as IMessage;
|
||||
if (subMessage != null)
|
||||
{
|
||||
// Google.Protobuf.WellKnownTypes.StringValue wrapper
|
||||
if (subMessage is Google.Protobuf.WellKnownTypes.StringValue sv
|
||||
&& ImageFieldNames.Contains(field.Name))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(sv.Value))
|
||||
{
|
||||
sv.Value = _fileManager.ResolveImageUrl(sv.Value);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ResolveImagePaths(subMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error resolving image path for field {Field}", field.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper برای Server Streaming — هر پیام قبل از ارسال resolve میشود
|
||||
/// </summary>
|
||||
private class ImageResolvingStreamWriter<T> : IServerStreamWriter<T> where T : class
|
||||
{
|
||||
private readonly IServerStreamWriter<T> _inner;
|
||||
private readonly ImagePathResolverInterceptor _interceptor;
|
||||
|
||||
public ImageResolvingStreamWriter(IServerStreamWriter<T> inner, ImagePathResolverInterceptor interceptor)
|
||||
{
|
||||
_inner = inner;
|
||||
_interceptor = interceptor;
|
||||
}
|
||||
|
||||
public WriteOptions? WriteOptions
|
||||
{
|
||||
get => _inner.WriteOptions;
|
||||
set => _inner.WriteOptions = value;
|
||||
}
|
||||
|
||||
public Task WriteAsync(T message)
|
||||
{
|
||||
if (message is IMessage msg)
|
||||
_interceptor.ResolveImagePaths(msg);
|
||||
return _inner.WriteAsync(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ builder.Services.AddGrpc(options =>
|
||||
options.Interceptors.Add<LoggingBehaviour>();
|
||||
options.Interceptors.Add<PerformanceBehaviour>();
|
||||
options.Interceptors.Add<CMSMicroservice.WebApi.Interceptors.PermissionInterceptor>();
|
||||
options.Interceptors.Add<CMSMicroservice.WebApi.Interceptors.ImagePathResolverInterceptor>();
|
||||
//options.Interceptors.Add<ExceptionHandlingBehaviour>();
|
||||
options.EnableDetailedErrors = true;
|
||||
options.MaxReceiveMessageSize = 1000 * 1024 * 1024; // 1 GB
|
||||
@@ -92,6 +93,13 @@ builder.Services.AddHealthChecks()
|
||||
// Add Controllers for REST APIs
|
||||
builder.Services.AddControllers();
|
||||
|
||||
// HttpClient for FMS fallback image download
|
||||
builder.Services.AddHttpClient("FMS", client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "FourSat-CMS/1.0");
|
||||
});
|
||||
|
||||
#region Configure Cors
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
|
||||
@@ -16,7 +16,6 @@ public class AppVersionService : AppVersionContract.AppVersionContractBase
|
||||
_dispatchRequestToCQRS = dispatchRequestToCQRS;
|
||||
}
|
||||
|
||||
[RequiresPermission(PermissionNames.SettingsView)]
|
||||
public override async Task<GetAppVersionResponse> GetAppVersion(GetAppVersionRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<GetAppVersionRequest, GetAppVersionQuery, GetAppVersionResponse>(request, context);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using CMSMicroservice.Protobuf.Protos.BlogCategory;
|
||||
using CMSMicroservice.WebApi.Common.Services;
|
||||
using CMSMicroservice.Application.BlogCategoryCQ.Commands.CreateBlogCategory;
|
||||
using CMSMicroservice.Application.BlogCategoryCQ.Commands.UpdateBlogCategory;
|
||||
using CMSMicroservice.Application.BlogCategoryCQ.Commands.DeleteBlogCategory;
|
||||
using CMSMicroservice.Application.BlogCategoryCQ.Queries.GetBlogCategory;
|
||||
using CMSMicroservice.Application.BlogCategoryCQ.Queries.GetAllBlogCategories;
|
||||
using CMSMicroservice.Application.BlogCategoryCQ.Queries.GetActiveBlogCategories;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using Mapster;
|
||||
using MediatR;
|
||||
using AppModels = CMSMicroservice.Application.Common.Models;
|
||||
using ProtoMetaData = CMSMicroservice.Protobuf.Protos.MetaData;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Services;
|
||||
|
||||
public class BlogCategoryService : BlogCategoryContract.BlogCategoryContractBase
|
||||
{
|
||||
private readonly ISender _sender;
|
||||
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
|
||||
|
||||
public BlogCategoryService(ISender sender, IDispatchRequestToCQRS dispatchRequestToCQRS)
|
||||
{
|
||||
_sender = sender;
|
||||
_dispatchRequestToCQRS = dispatchRequestToCQRS;
|
||||
}
|
||||
|
||||
public override async Task<CreateBlogCategoryResponse> CreateBlogCategory(CreateBlogCategoryRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<CreateBlogCategoryRequest, CreateBlogCategoryCommand, CreateBlogCategoryResponse>(request, context);
|
||||
}
|
||||
|
||||
public override async Task<Empty> UpdateBlogCategory(UpdateBlogCategoryRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<UpdateBlogCategoryRequest, UpdateBlogCategoryCommand>(request, context);
|
||||
}
|
||||
|
||||
public override async Task<Empty> DeleteBlogCategory(DeleteBlogCategoryRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<DeleteBlogCategoryRequest, DeleteBlogCategoryCommand>(request, context);
|
||||
}
|
||||
|
||||
public override async Task<GetBlogCategoryResponse> GetBlogCategory(GetBlogCategoryRequest request, ServerCallContext context)
|
||||
{
|
||||
var query = new GetBlogCategoryQuery { Id = request.Id };
|
||||
var result = await _sender.Send(query, context.CancellationToken);
|
||||
return MapCategoryToResponse(result);
|
||||
}
|
||||
|
||||
public override async Task<GetAllBlogCategoriesResponse> GetAllBlogCategories(GetAllBlogCategoriesRequest request, ServerCallContext context)
|
||||
{
|
||||
var query = new GetAllBlogCategoriesQuery
|
||||
{
|
||||
PageNumber = request.PaginationState?.PageNumber ?? 1,
|
||||
PageSize = request.PaginationState?.PageSize ?? 20,
|
||||
SearchTerm = request.SortBy
|
||||
};
|
||||
|
||||
var result = await _sender.Send(query, context.CancellationToken);
|
||||
var response = new GetAllBlogCategoriesResponse
|
||||
{
|
||||
MetaData = result.MetaData.Adapt<ProtoMetaData>()
|
||||
};
|
||||
|
||||
foreach (var item in result.Models)
|
||||
response.Models.Add(MapToListItem(item));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public override async Task<GetActiveBlogCategoriesResponse> GetActiveBlogCategories(GetActiveBlogCategoriesRequest request, ServerCallContext context)
|
||||
{
|
||||
var query = new GetActiveBlogCategoriesQuery();
|
||||
var result = await _sender.Send(query, context.CancellationToken);
|
||||
|
||||
var response = new GetActiveBlogCategoriesResponse();
|
||||
foreach (var item in result)
|
||||
response.Categories.Add(MapToListItem(item));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// ── Private Mapping Helpers ──
|
||||
|
||||
private static GetBlogCategoryResponse MapCategoryToResponse(BlogCategoryDto dto)
|
||||
{
|
||||
return new GetBlogCategoryResponse
|
||||
{
|
||||
Id = dto.Id,
|
||||
Title = dto.Title ?? string.Empty,
|
||||
Slug = dto.Slug ?? string.Empty,
|
||||
Description = dto.Description,
|
||||
IconName = dto.IconName,
|
||||
SortOrder = dto.SortOrder,
|
||||
IsActive = dto.IsActive,
|
||||
PostCount = dto.PostCount,
|
||||
Created = dto.Created != default ? Timestamp.FromDateTime(DateTime.SpecifyKind(dto.Created, DateTimeKind.Utc)) : null
|
||||
};
|
||||
}
|
||||
|
||||
private static BlogCategoryListItem MapToListItem(BlogCategoryDto dto)
|
||||
{
|
||||
return new BlogCategoryListItem
|
||||
{
|
||||
Id = dto.Id,
|
||||
Title = dto.Title ?? string.Empty,
|
||||
Slug = dto.Slug ?? string.Empty,
|
||||
Description = dto.Description,
|
||||
IconName = dto.IconName,
|
||||
SortOrder = dto.SortOrder,
|
||||
IsActive = dto.IsActive,
|
||||
PostCount = dto.PostCount
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Linq;
|
||||
using CMSMicroservice.Protobuf.Protos.BlogPostImage;
|
||||
using CMSMicroservice.WebApi.Common.Services;
|
||||
using CMSMicroservice.Application.BlogPostImageCQ.Commands.AddBlogPostImage;
|
||||
using CMSMicroservice.Application.BlogPostImageCQ.Commands.DeleteBlogPostImage;
|
||||
using CMSMicroservice.Application.BlogPostImageCQ.Commands.ReorderBlogPostImages;
|
||||
using CMSMicroservice.Application.BlogPostImageCQ.Queries.GetBlogPostImages;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Services;
|
||||
|
||||
public class BlogPostImageService : BlogPostImageContract.BlogPostImageContractBase
|
||||
{
|
||||
private readonly ISender _sender;
|
||||
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
|
||||
|
||||
public BlogPostImageService(ISender sender, IDispatchRequestToCQRS dispatchRequestToCQRS)
|
||||
{
|
||||
_sender = sender;
|
||||
_dispatchRequestToCQRS = dispatchRequestToCQRS;
|
||||
}
|
||||
|
||||
public override async Task<AddBlogPostImageResponse> AddBlogPostImage(AddBlogPostImageRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<AddBlogPostImageRequest, AddBlogPostImageCommand, AddBlogPostImageResponse>(request, context);
|
||||
}
|
||||
|
||||
public override async Task<Empty> DeleteBlogPostImage(DeleteBlogPostImageRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<DeleteBlogPostImageRequest, DeleteBlogPostImageCommand>(request, context);
|
||||
}
|
||||
|
||||
public override async Task<GetBlogPostImagesResponse> GetBlogPostImages(GetBlogPostImagesRequest request, ServerCallContext context)
|
||||
{
|
||||
var query = new GetBlogPostImagesQuery { BlogPostId = request.BlogPostId };
|
||||
var result = await _sender.Send(query, context.CancellationToken);
|
||||
|
||||
var response = new GetBlogPostImagesResponse();
|
||||
foreach (var item in result)
|
||||
{
|
||||
response.Images.Add(new BlogPostImageItem
|
||||
{
|
||||
Id = item.Id,
|
||||
BlogPostId = item.BlogPostId,
|
||||
ImagePath = item.ImagePath ?? string.Empty,
|
||||
ThumbnailPath = item.ThumbnailPath ?? string.Empty,
|
||||
AltText = item.AltText,
|
||||
Caption = item.Caption,
|
||||
SortOrder = item.SortOrder
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public override async Task<Empty> ReorderBlogPostImages(ReorderBlogPostImagesRequest request, ServerCallContext context)
|
||||
{
|
||||
var command = new ReorderBlogPostImagesCommand
|
||||
{
|
||||
Items = request.Items.Select(x => new Application.BlogPostImageCQ.Commands.ReorderBlogPostImages.ImageSortItem
|
||||
{
|
||||
Id = x.Id,
|
||||
SortOrder = x.SortOrder
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
await _sender.Send(command, context.CancellationToken);
|
||||
return new Empty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CMSMicroservice.Protobuf.Protos.BlogPost;
|
||||
using CMSMicroservice.WebApi.Common.Services;
|
||||
using CMSMicroservice.Application.BlogPostCQ.Commands.CreateBlogPost;
|
||||
using CMSMicroservice.Application.BlogPostCQ.Commands.UpdateBlogPost;
|
||||
using CMSMicroservice.Application.BlogPostCQ.Commands.DeleteBlogPost;
|
||||
using CMSMicroservice.Application.BlogPostCQ.Commands.PublishBlogPost;
|
||||
using CMSMicroservice.Application.BlogPostCQ.Commands.ArchiveBlogPost;
|
||||
using CMSMicroservice.Application.BlogPostCQ.Commands.IncrementViewCount;
|
||||
using CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPost;
|
||||
using CMSMicroservice.Application.BlogPostCQ.Queries.GetBlogPostBySlug;
|
||||
using CMSMicroservice.Application.BlogPostCQ.Queries.GetAllBlogPosts;
|
||||
using CMSMicroservice.Application.BlogPostCQ.Queries.GetPublishedBlogPosts;
|
||||
using CMSMicroservice.Application.BlogPostCQ.Queries.GetFeaturedBlogPosts;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using Mapster;
|
||||
using MediatR;
|
||||
using AppModels = CMSMicroservice.Application.Common.Models;
|
||||
using ProtoMetaData = CMSMicroservice.Protobuf.Protos.MetaData;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Services;
|
||||
|
||||
public class BlogPostService : BlogPostContract.BlogPostContractBase
|
||||
{
|
||||
private readonly ISender _sender;
|
||||
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
|
||||
|
||||
public BlogPostService(ISender sender, IDispatchRequestToCQRS dispatchRequestToCQRS)
|
||||
{
|
||||
_sender = sender;
|
||||
_dispatchRequestToCQRS = dispatchRequestToCQRS;
|
||||
}
|
||||
|
||||
public override async Task<CreateBlogPostResponse> CreateBlogPost(CreateBlogPostRequest request, ServerCallContext context)
|
||||
{
|
||||
var command = new CreateBlogPostCommand
|
||||
{
|
||||
Title = request.Title,
|
||||
Slug = request.Slug,
|
||||
Summary = request.Summary,
|
||||
HtmlContent = request.HtmlContent,
|
||||
FeaturedImagePath = request.FeaturedImagePath,
|
||||
FeaturedImageThumbnailPath = request.FeaturedImageThumbnailPath,
|
||||
CategoryIds = request.CategoryIds.ToList(),
|
||||
TagIds = request.TagIds.ToList(),
|
||||
IsFeatured = request.IsFeatured,
|
||||
SortOrder = request.SortOrder,
|
||||
ImageFileBytes = request.ImageFile?.File?.ToByteArray(),
|
||||
ImageFileMime = request.ImageFile?.Mime,
|
||||
ImageFileName = request.ImageFile?.FileName
|
||||
};
|
||||
|
||||
var result = await _sender.Send(command, context.CancellationToken);
|
||||
return new CreateBlogPostResponse { Id = result };
|
||||
}
|
||||
|
||||
public override async Task<Empty> UpdateBlogPost(UpdateBlogPostRequest request, ServerCallContext context)
|
||||
{
|
||||
var command = new UpdateBlogPostCommand
|
||||
{
|
||||
Id = request.Id,
|
||||
Title = request.Title,
|
||||
Slug = request.Slug,
|
||||
Summary = request.Summary,
|
||||
HtmlContent = request.HtmlContent,
|
||||
FeaturedImagePath = request.FeaturedImagePath,
|
||||
FeaturedImageThumbnailPath = request.FeaturedImageThumbnailPath,
|
||||
CategoryIds = request.CategoryIds.ToList(),
|
||||
TagIds = request.TagIds.ToList(),
|
||||
IsFeatured = request.IsFeatured,
|
||||
SortOrder = request.SortOrder,
|
||||
ImageFileBytes = request.ImageFile?.File?.ToByteArray(),
|
||||
ImageFileMime = request.ImageFile?.Mime,
|
||||
ImageFileName = request.ImageFile?.FileName
|
||||
};
|
||||
|
||||
await _sender.Send(command, context.CancellationToken);
|
||||
return new Empty();
|
||||
}
|
||||
|
||||
public override async Task<Empty> DeleteBlogPost(DeleteBlogPostRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<DeleteBlogPostRequest, DeleteBlogPostCommand>(request, context);
|
||||
}
|
||||
|
||||
public override async Task<GetBlogPostResponse> GetBlogPost(GetBlogPostRequest request, ServerCallContext context)
|
||||
{
|
||||
var query = new GetBlogPostQuery { Id = request.Id };
|
||||
var result = await _sender.Send(query, context.CancellationToken);
|
||||
return MapBlogPostDtoToResponse(result);
|
||||
}
|
||||
|
||||
public override async Task<GetBlogPostResponse> GetBlogPostBySlug(GetBlogPostBySlugRequest request, ServerCallContext context)
|
||||
{
|
||||
var query = new GetBlogPostBySlugQuery { Slug = request.Slug };
|
||||
var result = await _sender.Send(query, context.CancellationToken);
|
||||
return MapBlogPostDtoToResponse(result);
|
||||
}
|
||||
|
||||
public override async Task<GetAllBlogPostsResponse> GetAllBlogPosts(GetAllBlogPostsRequest request, ServerCallContext context)
|
||||
{
|
||||
var query = new GetAllBlogPostsQuery
|
||||
{
|
||||
PageNumber = request.PaginationState?.PageNumber ?? 1,
|
||||
PageSize = request.PaginationState?.PageSize ?? 10,
|
||||
SortBy = request.SortBy,
|
||||
SearchTerm = request.Filter?.SearchTerm,
|
||||
Status = request.Filter?.Status.HasValue == true ? (BlogPostStatus?)request.Filter.Status.Value : null,
|
||||
CategoryId = request.Filter?.CategoryId,
|
||||
IsFeatured = request.Filter?.IsFeatured
|
||||
};
|
||||
|
||||
var result = await _sender.Send(query, context.CancellationToken);
|
||||
return MapAllBlogPostsResponse(result);
|
||||
}
|
||||
|
||||
public override async Task<GetAllBlogPostsResponse> GetPublishedBlogPosts(GetPublishedBlogPostsRequest request, ServerCallContext context)
|
||||
{
|
||||
var query = new GetPublishedBlogPostsQuery
|
||||
{
|
||||
PageNumber = request.PaginationState?.PageNumber ?? 1,
|
||||
PageSize = request.PaginationState?.PageSize ?? 10,
|
||||
SearchTerm = request.SearchTerm,
|
||||
CategoryId = request.CategoryId
|
||||
};
|
||||
|
||||
var result = await _sender.Send(query, context.CancellationToken);
|
||||
return MapAllBlogPostsResponse(result);
|
||||
}
|
||||
|
||||
public override async Task<GetAllBlogPostsResponse> GetFeaturedBlogPosts(GetFeaturedBlogPostsRequest request, ServerCallContext context)
|
||||
{
|
||||
var query = new GetFeaturedBlogPostsQuery { Count = request.Count > 0 ? request.Count : 5 };
|
||||
var items = await _sender.Send(query, context.CancellationToken);
|
||||
|
||||
var response = new GetAllBlogPostsResponse();
|
||||
foreach (var item in items)
|
||||
response.Models.Add(MapToListItem(item));
|
||||
return response;
|
||||
}
|
||||
|
||||
public override async Task<PublishBlogPostResponse> PublishBlogPost(PublishBlogPostRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<PublishBlogPostRequest, PublishBlogPostCommand, PublishBlogPostResponse>(request, context);
|
||||
}
|
||||
|
||||
public override async Task<ArchiveBlogPostResponse> ArchiveBlogPost(ArchiveBlogPostRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<ArchiveBlogPostRequest, ArchiveBlogPostCommand, ArchiveBlogPostResponse>(request, context);
|
||||
}
|
||||
|
||||
public override async Task<Empty> IncrementViewCount(IncrementViewCountRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<IncrementViewCountRequest, IncrementViewCountCommand>(request, context);
|
||||
}
|
||||
|
||||
// ── Private Mapping Helpers ──
|
||||
|
||||
private static GetBlogPostResponse MapBlogPostDtoToResponse(BlogPostDto dto)
|
||||
{
|
||||
var response = new GetBlogPostResponse
|
||||
{
|
||||
Id = dto.Id,
|
||||
Title = dto.Title ?? string.Empty,
|
||||
Slug = dto.Slug ?? string.Empty,
|
||||
Summary = dto.Summary,
|
||||
HtmlContent = dto.HtmlContent ?? string.Empty,
|
||||
FeaturedImagePath = dto.FeaturedImagePath,
|
||||
FeaturedImageThumbnailPath = dto.FeaturedImageThumbnailPath,
|
||||
Status = (int)dto.Status,
|
||||
StatusName = dto.StatusName ?? string.Empty,
|
||||
ViewCount = dto.ViewCount,
|
||||
AuthorUserId = dto.AuthorUserId,
|
||||
IsFeatured = dto.IsFeatured,
|
||||
SortOrder = dto.SortOrder,
|
||||
Created = dto.Created != default ? Timestamp.FromDateTime(DateTime.SpecifyKind(dto.Created, DateTimeKind.Utc)) : null,
|
||||
LastModified = dto.LastModified.HasValue ? Timestamp.FromDateTime(DateTime.SpecifyKind(dto.LastModified.Value, DateTimeKind.Utc)) : null,
|
||||
PublishedAt = dto.PublishedAt.HasValue ? Timestamp.FromDateTime(DateTime.SpecifyKind(dto.PublishedAt.Value, DateTimeKind.Utc)) : null
|
||||
};
|
||||
|
||||
if (dto.Categories != null)
|
||||
foreach (var c in dto.Categories)
|
||||
response.Categories.Add(new BlogPostCategoryInfo { Id = c.Id, Title = c.Title ?? string.Empty, Slug = c.Slug ?? string.Empty });
|
||||
|
||||
if (dto.Tags != null)
|
||||
foreach (var t in dto.Tags)
|
||||
response.Tags.Add(new BlogPostTagInfo { Id = t.Id, Title = t.Title ?? string.Empty, Name = t.Name ?? string.Empty });
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static GetAllBlogPostsResponse MapAllBlogPostsResponse(GetAllBlogPostsResponseDto dto)
|
||||
{
|
||||
var response = new GetAllBlogPostsResponse
|
||||
{
|
||||
MetaData = dto.MetaData.Adapt<ProtoMetaData>()
|
||||
};
|
||||
|
||||
foreach (var item in dto.Models)
|
||||
response.Models.Add(MapToListItem(item));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static BlogPostListItem MapToListItem(BlogPostListItemDto item)
|
||||
{
|
||||
var listItem = new BlogPostListItem
|
||||
{
|
||||
Id = item.Id,
|
||||
Title = item.Title ?? string.Empty,
|
||||
Slug = item.Slug ?? string.Empty,
|
||||
Summary = item.Summary,
|
||||
FeaturedImageThumbnailPath = item.FeaturedImageThumbnailPath,
|
||||
Status = item.Status,
|
||||
StatusName = item.StatusName ?? string.Empty,
|
||||
ViewCount = item.ViewCount,
|
||||
IsFeatured = item.IsFeatured,
|
||||
Created = item.Created != default ? Timestamp.FromDateTime(DateTime.SpecifyKind(item.Created, DateTimeKind.Utc)) : null,
|
||||
PublishedAt = item.PublishedAt.HasValue ? Timestamp.FromDateTime(DateTime.SpecifyKind(item.PublishedAt.Value, DateTimeKind.Utc)) : null
|
||||
};
|
||||
|
||||
if (item.Categories != null)
|
||||
foreach (var c in item.Categories)
|
||||
listItem.Categories.Add(new BlogPostCategoryInfo { Id = c.Id, Title = c.Title ?? string.Empty, Slug = c.Slug ?? string.Empty });
|
||||
|
||||
return listItem;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using CMSMicroservice.Protobuf.Protos.DiscountOrder;
|
||||
using CMSMicroservice.WebApi.Common.Services;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.DiscountShopCQ.Commands.PlaceOrder;
|
||||
using CMSMicroservice.Application.DiscountShopCQ.Commands.CompleteOrderPayment;
|
||||
using CMSMicroservice.Application.DiscountShopCQ.Commands.UpdateOrderStatus;
|
||||
@@ -7,21 +8,57 @@ using CMSMicroservice.Application.DiscountShopCQ.Queries.GetOrderById;
|
||||
using CMSMicroservice.Application.DiscountShopCQ.Queries.GetUserOrders;
|
||||
using CMSMicroservice.Application.DiscountShopCQ.Queries.GetAllDiscountOrders;
|
||||
using CMSMicroservice.Application.DiscountShopCQ.Queries.GetDiscountSalesReport;
|
||||
using Grpc.Core;
|
||||
using Mapster;
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Services;
|
||||
|
||||
public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractBase
|
||||
{
|
||||
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
|
||||
private readonly ISender _sender;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
|
||||
public DiscountOrderService(IDispatchRequestToCQRS dispatchRequestToCQRS)
|
||||
public DiscountOrderService(
|
||||
IDispatchRequestToCQRS dispatchRequestToCQRS,
|
||||
ISender sender,
|
||||
ICurrentUserService currentUserService)
|
||||
{
|
||||
_dispatchRequestToCQRS = dispatchRequestToCQRS;
|
||||
_sender = sender;
|
||||
_currentUserService = currentUserService;
|
||||
}
|
||||
|
||||
private long GetCurrentUserId()
|
||||
{
|
||||
if (long.TryParse(_currentUserService.UserId, out var uid) && uid > 0)
|
||||
return uid;
|
||||
throw new RpcException(new Status(StatusCode.Unauthenticated, "کاربر احراز هویت نشده است"));
|
||||
}
|
||||
|
||||
public override async Task<PlaceOrderResponse> PlaceOrder(PlaceOrderRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<PlaceOrderRequest, PlaceOrderCommand, PlaceOrderResponse>(request, context);
|
||||
var command = new PlaceOrderCommand
|
||||
{
|
||||
UserId = GetCurrentUserId(),
|
||||
UserAddressId = request.UserAddressId,
|
||||
DiscountBalanceToUse = request.DiscountBalanceToUse
|
||||
};
|
||||
var result = await _sender.Send(command);
|
||||
|
||||
var response = new PlaceOrderResponse
|
||||
{
|
||||
Success = result.Success,
|
||||
Message = result.Message ?? string.Empty,
|
||||
OrderId = result.OrderId ?? 0,
|
||||
GatewayAmount = result.GatewayAmountRequired,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(result.PaymentUrl))
|
||||
response.PaymentUrl = result.PaymentUrl;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public override async Task<CompleteOrderPaymentResponse> CompleteOrderPayment(CompleteOrderPaymentRequest request, ServerCallContext context)
|
||||
@@ -36,12 +73,23 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB
|
||||
|
||||
public override async Task<GetOrderByIdResponse> GetOrderById(GetOrderByIdRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<GetOrderByIdRequest, GetOrderByIdQuery, GetOrderByIdResponse>(request, context);
|
||||
var query = new GetOrderByIdQuery
|
||||
{
|
||||
OrderId = request.OrderId,
|
||||
UserId = GetCurrentUserId()
|
||||
};
|
||||
var result = await _sender.Send(query);
|
||||
return result.Adapt<GetOrderByIdResponse>();
|
||||
}
|
||||
|
||||
public override async Task<GetUserOrdersResponse> GetUserOrders(GetUserOrdersRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<GetUserOrdersRequest, GetUserOrdersQuery, GetUserOrdersResponse>(request, context);
|
||||
var query = new GetUserOrdersQuery
|
||||
{
|
||||
UserId = GetCurrentUserId()
|
||||
};
|
||||
var result = await _sender.Send(query);
|
||||
return result.Adapt<GetUserOrdersResponse>();
|
||||
}
|
||||
|
||||
public override async Task<GetAllDiscountOrdersResponse> GetAllDiscountOrders(GetAllDiscountOrdersRequest request, ServerCallContext context)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CMSMicroservice.Protobuf.Protos.DiscountProduct;
|
||||
using CMSMicroservice.WebApi.Common.Services;
|
||||
using CMSMicroservice.Application.DiscountShopCQ.Commands.CreateDiscountProduct;
|
||||
@@ -10,26 +12,75 @@ using CMSMicroservice.Application.DiscountShopCQ.Commands.ReorderDiscountProduct
|
||||
using CMSMicroservice.Application.DiscountShopCQ.Queries.GetDiscountProductById;
|
||||
using CMSMicroservice.Application.DiscountShopCQ.Queries.GetDiscountProducts;
|
||||
using CMSMicroservice.Application.DiscountShopCQ.Queries.GetDiscountProductImages;
|
||||
using MediatR;
|
||||
using Mapster;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Services;
|
||||
|
||||
public class DiscountProductService : DiscountProductContract.DiscountProductContractBase
|
||||
{
|
||||
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
|
||||
private readonly ISender _sender;
|
||||
|
||||
public DiscountProductService(IDispatchRequestToCQRS dispatchRequestToCQRS)
|
||||
public DiscountProductService(IDispatchRequestToCQRS dispatchRequestToCQRS, ISender sender)
|
||||
{
|
||||
_dispatchRequestToCQRS = dispatchRequestToCQRS;
|
||||
_sender = sender;
|
||||
}
|
||||
|
||||
public override async Task<CreateDiscountProductResponse> CreateDiscountProduct(CreateDiscountProductRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<CreateDiscountProductRequest, CreateDiscountProductCommand, CreateDiscountProductResponse>(request, context);
|
||||
var command = new CreateDiscountProductCommand
|
||||
{
|
||||
Title = request.Title,
|
||||
ShortInfomation = request.ShortInfomation,
|
||||
FullInformation = request.FullInformation,
|
||||
Price = request.Price,
|
||||
MaxDiscountPercent = request.MaxDiscountPercent,
|
||||
ImagePath = request.ImagePath,
|
||||
ThumbnailPath = request.ThumbnailPath,
|
||||
SortOrder = request.SortOrder,
|
||||
IsActive = request.IsActive,
|
||||
CategoryIds = request.CategoryIds?.ToList() ?? new List<long>(),
|
||||
// Map binary image data
|
||||
ImageFileBytes = request.ImageFile?.File?.ToByteArray(),
|
||||
ImageFileMime = request.ImageFile?.Mime,
|
||||
ImageFileName = request.ImageFile?.FileName,
|
||||
ThumbnailFileBytes = request.ThumbnailFile?.File?.ToByteArray(),
|
||||
ThumbnailFileMime = request.ThumbnailFile?.Mime,
|
||||
ThumbnailFileName = request.ThumbnailFile?.FileName
|
||||
};
|
||||
|
||||
var productId = await _sender.Send(command, context.CancellationToken);
|
||||
return new CreateDiscountProductResponse { ProductId = productId };
|
||||
}
|
||||
|
||||
public override async Task<Empty> UpdateDiscountProduct(UpdateDiscountProductRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<UpdateDiscountProductRequest, UpdateDiscountProductCommand>(request, context);
|
||||
var command = new UpdateDiscountProductCommand
|
||||
{
|
||||
ProductId = request.ProductId,
|
||||
Title = request.Title,
|
||||
ShortInfomation = request.ShortInfomation,
|
||||
FullInformation = request.FullInformation,
|
||||
Price = request.Price,
|
||||
MaxDiscountPercent = request.MaxDiscountPercent,
|
||||
ImagePath = request.ImagePath,
|
||||
ThumbnailPath = request.ThumbnailPath,
|
||||
SortOrder = request.SortOrder,
|
||||
IsActive = request.IsActive,
|
||||
CategoryIds = request.CategoryIds?.ToList() ?? new List<long>(),
|
||||
// Map binary image data
|
||||
ImageFileBytes = request.ImageFile?.File?.ToByteArray(),
|
||||
ImageFileMime = request.ImageFile?.Mime,
|
||||
ImageFileName = request.ImageFile?.FileName,
|
||||
ThumbnailFileBytes = request.ThumbnailFile?.File?.ToByteArray(),
|
||||
ThumbnailFileMime = request.ThumbnailFile?.Mime,
|
||||
ThumbnailFileName = request.ThumbnailFile?.FileName
|
||||
};
|
||||
|
||||
await _sender.Send(command, context.CancellationToken);
|
||||
return new Empty();
|
||||
}
|
||||
|
||||
public override async Task<Empty> DeleteDiscountProduct(DeleteDiscountProductRequest request, ServerCallContext context)
|
||||
@@ -70,6 +121,11 @@ public class DiscountProductService : DiscountProductContract.DiscountProductCon
|
||||
|
||||
public override async Task<GetDiscountProductImagesResponse> GetDiscountProductImages(GetDiscountProductImagesRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<GetDiscountProductImagesRequest, GetDiscountProductImagesQuery, GetDiscountProductImagesResponse>(request, context);
|
||||
var query = request.Adapt<GetDiscountProductImagesQuery>();
|
||||
var images = await _sender.Send(query, context.CancellationToken);
|
||||
|
||||
var response = new GetDiscountProductImagesResponse();
|
||||
response.Images.AddRange(images.Select(i => i.Adapt<CMSMicroservice.Protobuf.Protos.DiscountProduct.DiscountProductImageDto>()));
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,111 @@
|
||||
using CMSMicroservice.Protobuf.Protos.DiscountShoppingCart;
|
||||
using CMSMicroservice.WebApi.Common.Services;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Application.DiscountShopCQ.Commands.AddToCart;
|
||||
using CMSMicroservice.Application.DiscountShopCQ.Commands.RemoveFromCart;
|
||||
using CMSMicroservice.Application.DiscountShopCQ.Commands.UpdateCartItemCount;
|
||||
using CMSMicroservice.Application.DiscountShopCQ.Commands.ClearCart;
|
||||
using CMSMicroservice.Application.DiscountShopCQ.Queries.GetUserCart;
|
||||
using Grpc.Core;
|
||||
using Mapster;
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Services;
|
||||
|
||||
public class DiscountShoppingCartService : DiscountShoppingCartContract.DiscountShoppingCartContractBase
|
||||
{
|
||||
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
|
||||
private readonly ISender _sender;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
|
||||
public DiscountShoppingCartService(IDispatchRequestToCQRS dispatchRequestToCQRS)
|
||||
public DiscountShoppingCartService(
|
||||
IDispatchRequestToCQRS dispatchRequestToCQRS,
|
||||
ISender sender,
|
||||
ICurrentUserService currentUserService)
|
||||
{
|
||||
_dispatchRequestToCQRS = dispatchRequestToCQRS;
|
||||
_sender = sender;
|
||||
_currentUserService = currentUserService;
|
||||
}
|
||||
|
||||
private long GetCurrentUserId()
|
||||
{
|
||||
if (long.TryParse(_currentUserService.UserId, out var uid) && uid > 0)
|
||||
return uid;
|
||||
throw new RpcException(new Status(StatusCode.Unauthenticated, "کاربر احراز هویت نشده است"));
|
||||
}
|
||||
|
||||
public override async Task<AddToCartResponse> AddToCart(AddToCartRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<AddToCartRequest, AddToCartCommand, AddToCartResponse>(request, context);
|
||||
var command = new AddToCartCommand
|
||||
{
|
||||
UserId = GetCurrentUserId(),
|
||||
ProductId = request.ProductId,
|
||||
Count = request.Count
|
||||
};
|
||||
var result = await _sender.Send(command, context.CancellationToken);
|
||||
return result.Adapt<AddToCartResponse>();
|
||||
}
|
||||
|
||||
public override async Task<RemoveFromCartResponse> RemoveFromCart(RemoveFromCartRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<RemoveFromCartRequest, RemoveFromCartCommand, RemoveFromCartResponse>(request, context);
|
||||
var command = new RemoveFromCartCommand
|
||||
{
|
||||
UserId = GetCurrentUserId(),
|
||||
ProductId = request.ProductId
|
||||
};
|
||||
var result = await _sender.Send(command, context.CancellationToken);
|
||||
return result.Adapt<RemoveFromCartResponse>();
|
||||
}
|
||||
|
||||
public override async Task<UpdateCartItemCountResponse> UpdateCartItemCount(UpdateCartItemCountRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<UpdateCartItemCountRequest, UpdateCartItemCountCommand, UpdateCartItemCountResponse>(request, context);
|
||||
var command = new UpdateCartItemCountCommand
|
||||
{
|
||||
UserId = GetCurrentUserId(),
|
||||
ProductId = request.ProductId,
|
||||
NewCount = request.NewCount
|
||||
};
|
||||
var result = await _sender.Send(command, context.CancellationToken);
|
||||
return result.Adapt<UpdateCartItemCountResponse>();
|
||||
}
|
||||
|
||||
public override async Task<GetUserCartResponse> GetUserCart(GetUserCartRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<GetUserCartRequest, GetUserCartQuery, GetUserCartResponse>(request, context);
|
||||
var query = new GetUserCartQuery { UserId = GetCurrentUserId() };
|
||||
var cart = await _sender.Send(query, context.CancellationToken);
|
||||
|
||||
var response = new GetUserCartResponse
|
||||
{
|
||||
TotalPrice = cart.TotalAmount,
|
||||
TotalDiscountAmount = cart.MaxDiscountAmount,
|
||||
FinalPrice = cart.MinPayableAmount
|
||||
};
|
||||
|
||||
foreach (var item in cart.Items)
|
||||
{
|
||||
response.Items.Add(new CMSMicroservice.Protobuf.Protos.DiscountShoppingCart.CartItemDto
|
||||
{
|
||||
ProductId = item.ProductId,
|
||||
ProductTitle = item.ProductTitle ?? string.Empty,
|
||||
ProductImagePath = item.ProductImagePath ?? string.Empty,
|
||||
UnitPrice = item.UnitPrice,
|
||||
MaxDiscountPercent = item.MaxDiscountPercent,
|
||||
Count = item.Count,
|
||||
TotalPrice = item.SubTotal,
|
||||
DiscountAmount = item.MaxDiscountAmount,
|
||||
FinalPrice = item.MinPayable,
|
||||
ProductRemainingCount = item.RemainingStock
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public override async Task<Empty> ClearCart(ClearCartRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<ClearCartRequest, ClearCartCommand>(request, context);
|
||||
var command = new ClearCartCommand { UserId = GetCurrentUserId() };
|
||||
await _sender.Send(command, context.CancellationToken);
|
||||
return new Empty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using CMSMicroservice.Application.Common.FileManager;
|
||||
using CMSMicroservice.Protobuf.Protos.ImageResolver;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// سرویس اختصاصی resolve تصاویر — مسیر نسبی را به base64 data-URI تبدیل میکند.
|
||||
/// FrontOffice از طریق این سرویس تمام تصاویر دینامیک را دریافت میکند.
|
||||
/// </summary>
|
||||
public class ImageResolverService : ImageResolverContract.ImageResolverContractBase
|
||||
{
|
||||
private readonly IFileManager _fileManager;
|
||||
|
||||
public ImageResolverService(IFileManager fileManager)
|
||||
{
|
||||
_fileManager = fileManager;
|
||||
}
|
||||
|
||||
public override Task<ResolveImagesResponse> ResolveImages(ResolveImagesRequest request, ServerCallContext context)
|
||||
{
|
||||
var response = new ResolveImagesResponse();
|
||||
|
||||
foreach (var path in request.Paths)
|
||||
{
|
||||
var dataUri = string.Empty;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
dataUri = _fileManager.ResolveImageUrl(path);
|
||||
}
|
||||
|
||||
response.Images.Add(new ResolvedImage
|
||||
{
|
||||
OriginalPath = path ?? string.Empty,
|
||||
DataUri = dataUri ?? string.Empty
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,7 @@ public class ProductsService : ProductsContract.ProductsContractBase
|
||||
CategoryIds = request.Filter?.CategoryId.HasValue == true
|
||||
? new List<long> { request.Filter.CategoryId.Value }
|
||||
: new List<long>(),
|
||||
IsActive = request.Filter?.IsActive,
|
||||
SortBy = request.SortBy ?? string.Empty,
|
||||
PaginationState = request.PaginationState != null
|
||||
? new AppModels.PaginationState
|
||||
@@ -216,7 +217,8 @@ public class ProductsService : ProductsContract.ProductsContractBase
|
||||
SaleCount = m.SaleCount,
|
||||
ViewCount = m.ViewCount,
|
||||
RemainingCount = m.RemainingCount,
|
||||
CategoryIds = { m.Categories?.Select(c => c.CategoryId) ?? Enumerable.Empty<long>() }
|
||||
CategoryIds = { m.Categories?.Select(c => c.CategoryId) ?? Enumerable.Empty<long>() },
|
||||
IsActive = m.IsActive
|
||||
}) }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
using System.Linq;
|
||||
using CMSMicroservice.Protobuf.Protos.SitePage;
|
||||
using CMSMicroservice.WebApi.Common.Services;
|
||||
using CMSMicroservice.Application.SitePageCQ.Commands.CreateSitePage;
|
||||
using CMSMicroservice.Application.SitePageCQ.Commands.DeleteSitePage;
|
||||
using CMSMicroservice.Application.SitePageCQ.Commands.UpdateSitePage;
|
||||
using CMSMicroservice.Application.SitePageCQ.Commands.CreateSitePageSection;
|
||||
using CMSMicroservice.Application.SitePageCQ.Commands.UpdateSitePageSection;
|
||||
using CMSMicroservice.Application.SitePageCQ.Commands.DeleteSitePageSection;
|
||||
using CMSMicroservice.Application.SitePageCQ.Commands.ReorderSitePageSections;
|
||||
using CMSMicroservice.Application.SitePageCQ.Queries.GetSitePage;
|
||||
using CMSMicroservice.Application.SitePageCQ.Queries.GetSitePageByKey;
|
||||
using CMSMicroservice.Application.SitePageCQ.Queries.GetAllSitePages;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Services;
|
||||
|
||||
public class SitePageService : SitePageContract.SitePageContractBase
|
||||
{
|
||||
private readonly ISender _sender;
|
||||
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
|
||||
|
||||
public SitePageService(ISender sender, IDispatchRequestToCQRS dispatchRequestToCQRS)
|
||||
{
|
||||
_sender = sender;
|
||||
_dispatchRequestToCQRS = dispatchRequestToCQRS;
|
||||
}
|
||||
|
||||
public override async Task<GetSitePageResponse> GetSitePage(GetSitePageRequest request, ServerCallContext context)
|
||||
{
|
||||
var query = new GetSitePageQuery { Id = request.Id };
|
||||
var result = await _sender.Send(query, context.CancellationToken);
|
||||
return MapToResponse(result);
|
||||
}
|
||||
|
||||
public override async Task<GetSitePageResponse> GetSitePageByKey(GetSitePageByKeyRequest request, ServerCallContext context)
|
||||
{
|
||||
var query = new GetSitePageByKeyQuery { PageKey = request.PageKey };
|
||||
var result = await _sender.Send(query, context.CancellationToken);
|
||||
return MapToResponse(result);
|
||||
}
|
||||
|
||||
public override async Task<Empty> UpdateSitePage(UpdateSitePageRequest request, ServerCallContext context)
|
||||
{
|
||||
var command = new UpdateSitePageCommand
|
||||
{
|
||||
Id = request.Id,
|
||||
Title = request.Title,
|
||||
MetaDescription = request.MetaDescription,
|
||||
HeroTitle = request.HeroTitle,
|
||||
HeroSubtitle = request.HeroSubtitle,
|
||||
HeroImagePath = request.HeroImagePath,
|
||||
IsActive = request.IsActive,
|
||||
ImageFileBytes = request.ImageFile?.File?.ToByteArray(),
|
||||
ImageFileMime = request.ImageFile?.Mime,
|
||||
ImageFileName = request.ImageFile?.FileName
|
||||
};
|
||||
|
||||
await _sender.Send(command, context.CancellationToken);
|
||||
return new Empty();
|
||||
}
|
||||
|
||||
public override async Task<CreateSitePageResponse> CreateSitePage(CreateSitePageRequest request, ServerCallContext context)
|
||||
{
|
||||
var command = new CreateSitePageCommand
|
||||
{
|
||||
PageKey = request.PageKey,
|
||||
Title = request.Title,
|
||||
MetaDescription = request.MetaDescription,
|
||||
HeroTitle = request.HeroTitle,
|
||||
HeroSubtitle = request.HeroSubtitle,
|
||||
IsActive = request.IsActive,
|
||||
ImageFileBytes = request.ImageFile?.File?.ToByteArray(),
|
||||
ImageFileMime = request.ImageFile?.Mime,
|
||||
ImageFileName = request.ImageFile?.FileName
|
||||
};
|
||||
|
||||
var id = await _sender.Send(command, context.CancellationToken);
|
||||
return new CreateSitePageResponse { Id = id };
|
||||
}
|
||||
|
||||
public override async Task<Empty> DeleteSitePage(DeleteSitePageRequest request, ServerCallContext context)
|
||||
{
|
||||
var command = new DeleteSitePageCommand { Id = request.Id };
|
||||
await _sender.Send(command, context.CancellationToken);
|
||||
return new Empty();
|
||||
}
|
||||
|
||||
public override async Task<GetAllSitePagesResponse> GetAllSitePages(GetAllSitePagesRequest request, ServerCallContext context)
|
||||
{
|
||||
var query = new GetAllSitePagesQuery();
|
||||
var result = await _sender.Send(query, context.CancellationToken);
|
||||
|
||||
var response = new GetAllSitePagesResponse();
|
||||
foreach (var item in result)
|
||||
{
|
||||
response.Pages.Add(new SitePageSummary
|
||||
{
|
||||
Id = item.Id,
|
||||
PageKey = item.PageKey ?? string.Empty,
|
||||
Title = item.Title ?? string.Empty,
|
||||
IsActive = item.IsActive,
|
||||
SectionCount = item.SectionCount,
|
||||
LastModified = item.LastModified.HasValue
|
||||
? Timestamp.FromDateTime(DateTime.SpecifyKind(item.LastModified.Value, DateTimeKind.Utc))
|
||||
: null
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public override async Task<CreateSitePageSectionResponse> CreateSitePageSection(CreateSitePageSectionRequest request, ServerCallContext context)
|
||||
{
|
||||
var command = new CreateSitePageSectionCommand
|
||||
{
|
||||
SitePageId = request.SitePageId,
|
||||
SectionKey = request.SectionKey,
|
||||
Title = request.Title,
|
||||
Subtitle = request.Subtitle,
|
||||
HtmlContent = request.HtmlContent,
|
||||
IconName = request.IconName,
|
||||
ImagePath = request.ImagePath,
|
||||
IsActive = true,
|
||||
ExtraData = request.ExtraData,
|
||||
ImageFileBytes = request.ImageFile?.File?.ToByteArray(),
|
||||
ImageFileMime = request.ImageFile?.Mime,
|
||||
ImageFileName = request.ImageFile?.FileName
|
||||
};
|
||||
|
||||
var result = await _sender.Send(command, context.CancellationToken);
|
||||
return new CreateSitePageSectionResponse { Id = result };
|
||||
}
|
||||
|
||||
public override async Task<Empty> UpdateSitePageSection(UpdateSitePageSectionRequest request, ServerCallContext context)
|
||||
{
|
||||
var command = new UpdateSitePageSectionCommand
|
||||
{
|
||||
Id = request.Id,
|
||||
SectionKey = request.SectionKey,
|
||||
Title = request.Title,
|
||||
Subtitle = request.Subtitle,
|
||||
HtmlContent = request.HtmlContent,
|
||||
IconName = request.IconName,
|
||||
ImagePath = request.ImagePath,
|
||||
SortOrder = request.SortOrder,
|
||||
IsActive = request.IsActive,
|
||||
ExtraData = request.ExtraData,
|
||||
ImageFileBytes = request.ImageFile?.File?.ToByteArray(),
|
||||
ImageFileMime = request.ImageFile?.Mime,
|
||||
ImageFileName = request.ImageFile?.FileName
|
||||
};
|
||||
|
||||
await _sender.Send(command, context.CancellationToken);
|
||||
return new Empty();
|
||||
}
|
||||
|
||||
public override async Task<Empty> DeleteSitePageSection(DeleteSitePageSectionRequest request, ServerCallContext context)
|
||||
{
|
||||
return await _dispatchRequestToCQRS.Handle<DeleteSitePageSectionRequest, DeleteSitePageSectionCommand>(request, context);
|
||||
}
|
||||
|
||||
public override async Task<Empty> ReorderSitePageSections(ReorderSitePageSectionsRequest request, ServerCallContext context)
|
||||
{
|
||||
var command = new ReorderSitePageSectionsCommand
|
||||
{
|
||||
Items = request.Items.Select(x => new Application.SitePageCQ.Commands.ReorderSitePageSections.SectionSortItem
|
||||
{
|
||||
Id = x.Id,
|
||||
SortOrder = x.SortOrder
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
await _sender.Send(command, context.CancellationToken);
|
||||
return new Empty();
|
||||
}
|
||||
|
||||
// ── Private Mapping Helpers ──
|
||||
|
||||
private static GetSitePageResponse MapToResponse(SitePageDto dto)
|
||||
{
|
||||
var response = new GetSitePageResponse
|
||||
{
|
||||
Id = dto.Id,
|
||||
PageKey = dto.PageKey ?? string.Empty,
|
||||
Title = dto.Title ?? string.Empty,
|
||||
MetaDescription = dto.MetaDescription,
|
||||
HeroTitle = dto.HeroTitle,
|
||||
HeroSubtitle = dto.HeroSubtitle,
|
||||
HeroImagePath = dto.HeroImagePath,
|
||||
IsActive = dto.IsActive
|
||||
};
|
||||
|
||||
if (dto.Sections != null)
|
||||
{
|
||||
foreach (var s in dto.Sections)
|
||||
{
|
||||
response.Sections.Add(new SitePageSectionItem
|
||||
{
|
||||
Id = s.Id,
|
||||
SectionKey = s.SectionKey ?? string.Empty,
|
||||
Title = s.Title ?? string.Empty,
|
||||
Subtitle = s.Subtitle,
|
||||
HtmlContent = s.HtmlContent,
|
||||
IconName = s.IconName,
|
||||
ImagePath = s.ImagePath,
|
||||
SortOrder = s.SortOrder,
|
||||
IsActive = s.IsActive,
|
||||
ExtraData = s.ExtraData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -167,14 +167,14 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
|
||||
{
|
||||
UserId = request.Filter?.UserId ?? 0, // 0 means all users (admin view)
|
||||
PaginationState = request.PaginationState?.Adapt<AppModels.PaginationState>(),
|
||||
PaymentStatusFilter = request.Filter?.PaymentStatus != null
|
||||
PaymentStatusFilter = request.Filter?.HasPaymentStatus == true
|
||||
? (int?)request.Filter.PaymentStatus
|
||||
: null,
|
||||
DeliveryStatusFilter = request.Filter?.DeliveryStatus != null
|
||||
DeliveryStatusFilter = request.Filter?.HasDeliveryStatus == true
|
||||
? (int?)request.Filter.DeliveryStatus
|
||||
: null,
|
||||
FromDate = request.Filter?.PaymentDate?.ToDateTime(),
|
||||
ToDate = null
|
||||
FromDate = request.Filter?.FromDate?.ToDateTime() ?? request.Filter?.PaymentDate?.ToDateTime(),
|
||||
ToDate = request.Filter?.ToDate?.ToDateTime()
|
||||
};
|
||||
|
||||
var result = await _sender.Send(query, context.CancellationToken);
|
||||
@@ -261,7 +261,14 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
|
||||
|
||||
if (defaultAddress == null)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.FailedPrecondition, "آدرس پیشفرض یافت نشد"));
|
||||
// Check if user has any address at all
|
||||
var hasAnyAddress = await _context.UserAddresses
|
||||
.AnyAsync(a => a.UserId == userId && !a.IsDeleted, context.CancellationToken);
|
||||
|
||||
throw new RpcException(new Status(StatusCode.FailedPrecondition,
|
||||
hasAnyAddress
|
||||
? "لطفاً یک آدرس را به عنوان پیشفرض انتخاب کنید."
|
||||
: "آدرسی ثبت نشده است. لطفاً ابتدا یک آدرس اضافه کنید."));
|
||||
}
|
||||
|
||||
// Calculate amounts
|
||||
@@ -591,14 +598,14 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
|
||||
{
|
||||
UserId = customerUserId,
|
||||
PaginationState = request.PaginationState?.Adapt<AppModels.PaginationState>(),
|
||||
PaymentStatusFilter = request.Filter?.PaymentStatus != null
|
||||
PaymentStatusFilter = request.Filter?.HasPaymentStatus == true
|
||||
? (int?)request.Filter.PaymentStatus
|
||||
: null,
|
||||
DeliveryStatusFilter = request.Filter?.DeliveryStatus != null
|
||||
DeliveryStatusFilter = request.Filter?.HasDeliveryStatus == true
|
||||
? (int?)request.Filter.DeliveryStatus
|
||||
: null,
|
||||
FromDate = request.Filter?.PaymentDate?.ToDateTime(),
|
||||
ToDate = null
|
||||
FromDate = request.Filter?.FromDate?.ToDateTime() ?? request.Filter?.PaymentDate?.ToDateTime(),
|
||||
ToDate = request.Filter?.ToDate?.ToDateTime()
|
||||
};
|
||||
|
||||
var result = await _sender.Send(query, context.CancellationToken);
|
||||
|
||||
@@ -34,7 +34,7 @@ public class UserService : UserContract.UserContractBase
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
private readonly IHashService _hashService;
|
||||
private readonly IFileManagementService _fileManagementService;
|
||||
private readonly CMSMicroservice.Application.Common.FileManager.IFileManager _fileManager;
|
||||
|
||||
public UserService(
|
||||
IDispatchRequestToCQRS dispatchRequestToCQRS,
|
||||
@@ -42,14 +42,14 @@ public class UserService : UserContract.UserContractBase
|
||||
IApplicationDbContext context,
|
||||
ICurrentUserService currentUserService,
|
||||
IHashService hashService,
|
||||
IFileManagementService fileManagementService)
|
||||
CMSMicroservice.Application.Common.FileManager.IFileManager fileManager)
|
||||
{
|
||||
_dispatchRequestToCQRS = dispatchRequestToCQRS;
|
||||
_sender = sender;
|
||||
_context = context;
|
||||
_currentUserService = currentUserService;
|
||||
_hashService = hashService;
|
||||
_fileManagementService = fileManagementService;
|
||||
_fileManager = fileManager;
|
||||
}
|
||||
public override async Task<CreateNewUserResponse> CreateNewUser(CreateNewUserRequest request, ServerCallContext context)
|
||||
{
|
||||
@@ -65,6 +65,10 @@ public class UserService : UserContract.UserContractBase
|
||||
}
|
||||
public override async Task<GetUserResponse> GetUser(GetUserRequest request, ServerCallContext context)
|
||||
{
|
||||
// اگر Id ارسال نشده، از JWT بخون (برای کلاینت مشتری)
|
||||
if (request.Id == 0)
|
||||
request.Id = GetCurrentUserId();
|
||||
|
||||
return await _dispatchRequestToCQRS.Handle<GetUserRequest, GetUserQuery, GetUserResponse>(request, context);
|
||||
}
|
||||
public override async Task<GetAllUserByFilterResponse> GetAllUserByFilter(GetAllUserByFilterRequest request, ServerCallContext context)
|
||||
@@ -335,15 +339,28 @@ public class UserService : UserContract.UserContractBase
|
||||
if (user == null)
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "کاربر یافت نشد"));
|
||||
|
||||
// Upload to FMS
|
||||
// Upload to local file manager
|
||||
var fileBytes = request.FileData.ToByteArray();
|
||||
var fileName = $"avatar_{userId}_{DateTime.UtcNow.Ticks}";
|
||||
var mime = request.FileMimeType ?? "image/jpeg";
|
||||
|
||||
var avatarUrl = await _fileManagementService.UploadFileAsync(
|
||||
"Avatars", fileBytes, mime, fileName, context.CancellationToken);
|
||||
|
||||
if (string.IsNullOrEmpty(avatarUrl))
|
||||
try
|
||||
{
|
||||
var result = await _fileManager.UploadImageAsync(
|
||||
"Avatars", fileBytes, mime, fileName, context.CancellationToken);
|
||||
|
||||
// Update user avatar path in DB
|
||||
user.AvatarPath = result.Main.Path;
|
||||
await _context.SaveChangesAsync(context.CancellationToken);
|
||||
|
||||
return new UploadCustomerAvatarResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "تصویر پروفایل با موفقیت آپلود شد",
|
||||
AvatarUrl = result.Main.Path
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UploadCustomerAvatarResponse
|
||||
{
|
||||
@@ -351,17 +368,6 @@ public class UserService : UserContract.UserContractBase
|
||||
Message = "خطا در آپلود فایل. لطفاً مجدد تلاش کنید"
|
||||
};
|
||||
}
|
||||
|
||||
// Update user avatar path in DB
|
||||
user.AvatarPath = avatarUrl;
|
||||
await _context.SaveChangesAsync(context.CancellationToken);
|
||||
|
||||
return new UploadCustomerAvatarResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "تصویر پروفایل با موفقیت آپلود شد",
|
||||
AvatarUrl = avatarUrl
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<GetCustomerSettingsResponse> GetCustomerSettings(GetCustomerSettingsRequest request, ServerCallContext context)
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
{
|
||||
"UseRealPaymentGateway": false,
|
||||
"PaymentProvider": "pyms",
|
||||
"PYMS": {
|
||||
"Address": "https://pyms.se.kbs1.ir"
|
||||
},
|
||||
"ZarinPal": {
|
||||
"MerchantId": "00000000-0000-0000-0000-000000000000",
|
||||
"UseSandbox": true
|
||||
},
|
||||
"CmsBaseUrl": "https://localhost:32846",
|
||||
"FrontOfficeBaseUrl": "https://localhost:5268",
|
||||
"JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=",
|
||||
"JwtIssuer": "https://localhost",
|
||||
"JwtAudience": "https://localhost",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
{
|
||||
"UseRealPaymentGateway": false,
|
||||
"PaymentProvider": "pyms",
|
||||
"PYMS": {
|
||||
"Address": "http://pyms-svc.default.svc.cluster.local:80"
|
||||
},
|
||||
"ZarinPal": {
|
||||
"MerchantId": "6b098fc8-f490-47a1-aac3-1de1a1b84404",
|
||||
"UseSandbox": true
|
||||
},
|
||||
"FMS": {
|
||||
"Address": "https://dl.afrino.co"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user