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:
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user