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

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:
masoodafar-web
2026-02-15 23:01:16 +03:30
parent 5a4e4a960d
commit 2502cbbda2
177 changed files with 16632 additions and 487 deletions
@@ -1,139 +0,0 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Protobuf.Protos.FMS;
using Google.Protobuf;
using Grpc.Net.Client;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
using System.IO;
namespace CMSMicroservice.Infrastructure.Services;
public class FileManagementService : IFileManagementService, IDisposable
{
private readonly ILogger<FileManagementService> _logger;
private readonly FileInfoContract.FileInfoContractClient _client;
private readonly GrpcChannel _channel;
private const int MainImageMaxWidth = 1200;
private const int MainImageMaxHeight = 1200;
private const int ThumbnailMaxWidth = 300;
private const int ThumbnailMaxHeight = 300;
private const int JpegQuality = 75;
public FileManagementService(IConfiguration configuration, ILogger<FileManagementService> logger)
{
_logger = logger;
var fmsAddress = configuration["FMS:Address"] ?? "https://dl.afrino.co";
_channel = GrpcChannel.ForAddress(fmsAddress, new GrpcChannelOptions
{
MaxReceiveMessageSize = 100 * 1024 * 1024, // 100 MB
MaxSendMessageSize = 100 * 1024 * 1024
});
_client = new FileInfoContract.FileInfoContractClient(_channel);
}
public async Task<string?> UploadFileAsync(string directory, byte[] fileBytes, string mime, string? fileName,
CancellationToken cancellationToken = default)
{
try
{
var request = new CreateNewFileInfoRequest
{
Directory = directory,
File = ByteString.CopyFrom(fileBytes),
Mime = mime,
IsBase64 = false
};
if (!string.IsNullOrWhiteSpace(fileName))
request.FileName = fileName;
var response = await _client.CreateNewFileInfoAsync(request, cancellationToken: cancellationToken);
if (response != null && !string.IsNullOrWhiteSpace(response.File))
{
_logger.LogInformation("File uploaded to FMS successfully. Id: {Id}, Path: {Path}", response.Id, response.File);
return response.File;
}
_logger.LogWarning("FMS upload returned null or empty path for file: {FileName}", fileName);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading file to FMS. Directory: {Directory}, FileName: {FileName}", directory, fileName);
return null;
}
}
public async Task<(string? MainImagePath, string? ThumbnailPath)> UploadImageWithThumbnailAsync(
string directory, byte[] fileBytes, string mime, string? fileName,
CancellationToken cancellationToken = default)
{
string? mainImagePath = null;
string? thumbnailPath = null;
try
{
// Optimize main image
var mainImageBytes = await OptimizeImageAsync(fileBytes, MainImageMaxWidth, MainImageMaxHeight);
mainImagePath = await UploadFileAsync(directory, mainImageBytes, "image/jpeg", fileName, cancellationToken);
// Create and upload thumbnail
var thumbnailBytes = await OptimizeImageAsync(fileBytes, ThumbnailMaxWidth, ThumbnailMaxHeight);
var thumbFileName = fileName != null ? $"thumb_{fileName}" : null;
thumbnailPath = await UploadFileAsync($"{directory}/Thumbnails", thumbnailBytes, "image/jpeg", thumbFileName, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing and uploading image with thumbnail. Directory: {Directory}", directory);
}
return (mainImagePath, thumbnailPath);
}
public async Task<bool> DeleteFileAsync(long fileId, CancellationToken cancellationToken = default)
{
try
{
var request = new DeleteFileInfoRequest { Id = fileId };
var response = await _client.DeleteFileInfoAsync(request, cancellationToken: cancellationToken);
return response?.Success ?? false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting file from FMS. FileId: {FileId}", fileId);
return false;
}
}
private static async Task<byte[]> OptimizeImageAsync(byte[] imageBytes, int maxWidth, int maxHeight)
{
using var image = Image.Load(imageBytes);
// Only resize if larger than max dimensions
if (image.Width > maxWidth || image.Height > maxHeight)
{
image.Mutate(x => x.Resize(new ResizeOptions
{
Size = new Size(maxWidth, maxHeight),
Mode = ResizeMode.Max
}));
}
using var ms = new MemoryStream();
await image.SaveAsJpegAsync(ms, new JpegEncoder { Quality = JpegQuality });
return ms.ToArray();
}
public void Dispose()
{
_channel?.Dispose();
}
}
@@ -0,0 +1,260 @@
using System.IO;
using System.Net.Http;
using CMSMicroservice.Application.Common.FileManager;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
namespace CMSMicroservice.Infrastructure.Services;
/// <summary>
/// فایل‌منیجر محلی — فایل‌ها روی دیسک ذخیره می‌شوند
/// مسیر نسبی در دیتابیس ذخیره می‌شود
/// موقع واکشی: فایل از دیسک خوانده و به base64 data-URI تبدیل می‌شود
/// </summary>
public sealed class LocalFileManager : IFileManager
{
private readonly ILogger<LocalFileManager> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly string _uploadRoot;
private readonly string _fmsBaseUrl;
// ── تنظیمات بهینه‌سازی تصویر ──
private const int MainMaxWidth = 1200;
private const int MainMaxHeight = 1200;
private const int ThumbMaxWidth = 300;
private const int ThumbMaxHeight = 300;
private const int JpegQuality = 75;
public LocalFileManager(IConfiguration configuration, IHttpClientFactory httpClientFactory, ILogger<LocalFileManager> logger)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
// مسیر ذخیره فایل‌ها — پیش‌فرض: پوشه Uploads در کنار WebApi
_uploadRoot = configuration["FileStorage:UploadPath"]
?? Path.Combine(AppContext.BaseDirectory, "Uploads");
_fmsBaseUrl = configuration["FMS:Address"]?.TrimEnd('/') ?? "https://dl.afrino.co";
Directory.CreateDirectory(_uploadRoot);
_logger.LogInformation("LocalFileManager initialized — UploadRoot: {Root}", _uploadRoot);
}
// ────────────────────────────────────────────────────
// آپلود فایل خام → ذخیره روی دیسک → برگرداندن مسیر نسبی
// ────────────────────────────────────────────────────
public async Task<UploadedFile> UploadAsync(
string directory, byte[] fileBytes, string mime,
string? fileName = null, CancellationToken ct = default)
{
if (fileBytes is not { Length: > 0 })
throw new FileUploadException("فایلی برای آپلود ارسال نشده است");
try
{
var ext = GetExtension(mime, fileName);
var uniqueName = $"{Guid.NewGuid():N}{ext}";
var relativePath = Path.Combine(directory, uniqueName).Replace('\\', '/');
var fullPath = Path.Combine(_uploadRoot, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
await File.WriteAllBytesAsync(fullPath, fileBytes, ct);
_logger.LogInformation(
"File saved — Path: {Path}, Size: {Size}KB",
relativePath, fileBytes.Length / 1024);
return new UploadedFile(0, relativePath);
}
catch (FileUploadException) { throw; }
catch (Exception ex)
{
_logger.LogError(ex, "خطا در ذخیره فایل — Directory: {Dir}", directory);
throw new FileUploadException($"خطا در ذخیره فایل: {ex.Message}", ex);
}
}
// ────────────────────────────────────────────────────
// آپلود تصویر + بندانگشتی → ذخیره روی دیسک
// ────────────────────────────────────────────────────
public async Task<UploadedImage> UploadImageAsync(
string directory, byte[] fileBytes, string mime,
string? fileName = null, CancellationToken ct = default)
{
if (fileBytes is not { Length: > 0 })
throw new FileUploadException("تصویری برای آپلود ارسال نشده است");
var baseName = Guid.NewGuid().ToString("N");
// ① بهینه‌سازی و ذخیره تصویر اصلی
var mainBytes = await OptimizeAsync(fileBytes, MainMaxWidth, MainMaxHeight);
var mainRelative = Path.Combine(directory, $"{baseName}.jpg").Replace('\\', '/');
var mainFull = Path.Combine(_uploadRoot, mainRelative);
Directory.CreateDirectory(Path.GetDirectoryName(mainFull)!);
await File.WriteAllBytesAsync(mainFull, mainBytes, ct);
var main = new UploadedFile(0, mainRelative);
// ② ساخت و ذخیره بندانگشتی
var thumbBytes = await OptimizeAsync(fileBytes, ThumbMaxWidth, ThumbMaxHeight);
var thumbRelative = Path.Combine(directory, $"{baseName}_thumb.jpg").Replace('\\', '/');
var thumbFull = Path.Combine(_uploadRoot, thumbRelative);
await File.WriteAllBytesAsync(thumbFull, thumbBytes, ct);
var thumb = new UploadedFile(0, thumbRelative);
_logger.LogInformation(
"Image saved — Main: {MainPath} ({MainKB}KB), Thumb: {ThumbPath} ({ThumbKB}KB)",
mainRelative, mainBytes.Length / 1024,
thumbRelative, thumbBytes.Length / 1024);
return new UploadedImage(main, thumb);
}
// ────────────────────────────────────────────────────
// حذف فایل از دیسک
// ────────────────────────────────────────────────────
public Task DeleteAsync(long fileId, CancellationToken ct = default)
{
_logger.LogWarning("DeleteAsync called with fileId={Id} — file deletion by ID not supported in disk mode", fileId);
return Task.CompletedTask;
}
// ────────────────────────────────────────────────────
// خواندن فایل از دیسک → تبدیل به base64 data-URI
// ────────────────────────────────────────────────────
public string ResolveImageUrl(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return string.Empty;
// اگر از قبل data-URI یا URL مطلق هست، همان را برگردان
if (path.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return path;
try
{
var fullPath = Path.Combine(_uploadRoot, path.TrimStart('/'));
if (!File.Exists(fullPath))
{
_logger.LogWarning("Image file not found on disk, trying FMS fallback: {Path}", fullPath);
// ── FMS Fallback: دانلود از dl.afrino.co و کش محلی (برای مهاجرت) ──
if (!TryDownloadFromFms(path.TrimStart('/'), fullPath))
return string.Empty;
_logger.LogInformation("Downloaded and cached from FMS: {Path}", path);
}
var bytes = File.ReadAllBytes(fullPath);
var mime = GetMimeFromExtension(Path.GetExtension(fullPath));
return $"data:{mime};base64,{Convert.ToBase64String(bytes)}";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reading image from disk: {Path}", path);
return string.Empty;
}
}
// ────────────────────────────────────────────────────
// FMS Fallback — دانلود از سرور قدیمی و کش محلی (مهاجرت)
// ────────────────────────────────────────────────────
private bool TryDownloadFromFms(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 = client.Send(new HttpRequestMessage(HttpMethod.Get, fmsUrl),
HttpCompletionOption.ResponseHeadersRead);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("FMS returned {Status} for {Url}", response.StatusCode, fmsUrl);
return false;
}
// ذخیره روی دیسک
var directory = Path.GetDirectoryName(localPath)!;
Directory.CreateDirectory(directory);
using var responseStream = response.Content.ReadAsStream();
using var fileStream = new FileStream(localPath, FileMode.Create, FileAccess.Write, FileShare.None);
responseStream.CopyTo(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;
}
}
// ────────────────────────────────────────────────────
// بهینه‌سازی تصویر (ریسایز + فشرده‌سازی JPEG)
// ────────────────────────────────────────────────────
private static async Task<byte[]> OptimizeAsync(byte[] imageBytes, int maxWidth, int maxHeight)
{
using var image = SixLabors.ImageSharp.Image.Load(imageBytes);
if (image.Width > maxWidth || image.Height > maxHeight)
{
image.Mutate(x => x.Resize(new ResizeOptions
{
Size = new Size(maxWidth, maxHeight),
Mode = ResizeMode.Max
}));
}
using var ms = new MemoryStream();
await image.SaveAsJpegAsync(ms, new JpegEncoder { Quality = JpegQuality });
return ms.ToArray();
}
// ────────────────────────────────────────────────────
// پسوند فایل از mime type
// ────────────────────────────────────────────────────
private static string GetExtension(string mime, string? fileName)
{
if (!string.IsNullOrEmpty(fileName))
{
var ext = Path.GetExtension(fileName);
if (!string.IsNullOrEmpty(ext))
return ext.ToLowerInvariant();
}
return mime.ToLowerInvariant() switch
{
"image/jpeg" or "image/jpg" => ".jpg",
"image/png" => ".png",
"image/gif" => ".gif",
"image/webp" => ".webp",
"image/svg+xml" => ".svg",
"application/pdf" => ".pdf",
_ => ".bin"
};
}
private static string GetMimeFromExtension(string extension)
{
return extension.ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".svg" => "image/svg+xml",
".pdf" => "application/pdf",
_ => "application/octet-stream"
};
}
}
@@ -0,0 +1,284 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Protobuf.Protos.PYMS;
using CMSMicroservice.Protobuf.Protos.PYMS.Transaction;
using Grpc.Net.Client;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Net.Http;
namespace CMSMicroservice.Infrastructure.Services.Payment;
/// <summary>
/// پیاده‌سازی درگاه پرداخت از طریق PYMS (Payment Microservice)
/// CMS به جای اتصال مستقیم به ZarinPal، از PYMS استفاده می‌کند.
/// PYMS تراکنش‌ها را ذخیره و با ZarinPal ارتباط برقرار می‌کند.
/// </summary>
public class PYMSPaymentService : IPaymentGatewayService, IDisposable
{
private readonly ILogger<PYMSPaymentService> _logger;
private readonly GrpcChannel _channel;
private readonly TransactionContract.TransactionContractClient _client;
private readonly string _merchantId;
private readonly bool _useSandbox;
public PYMSPaymentService(
IConfiguration configuration,
ILogger<PYMSPaymentService> logger)
{
_logger = logger;
var pymsAddress = configuration["PYMS:Address"]
?? throw new InvalidOperationException("PYMS:Address is not configured.");
_merchantId = configuration["ZarinPal:MerchantId"]
?? throw new InvalidOperationException("ZarinPal:MerchantId is not configured.");
_useSandbox = configuration.GetValue<bool>("ZarinPal:UseSandbox", true);
// ایجاد کانال gRPC به PYMS
_channel = GrpcChannel.ForAddress(pymsAddress, new GrpcChannelOptions
{
HttpHandler = new SocketsHttpHandler
{
EnableMultipleHttp2Connections = true,
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5),
KeepAlivePingDelay = TimeSpan.FromSeconds(60),
KeepAlivePingTimeout = TimeSpan.FromSeconds(30),
}
});
_client = new TransactionContract.TransactionContractClient(_channel);
_logger.LogInformation(
"PYMS Payment Service initialized. Address={Address}, Mode={Mode}",
pymsAddress, _useSandbox ? "🧪 Sandbox" : "🏦 Production");
}
/// <summary>
/// مرحله ۱: ارسال درخواست پرداخت به PYMS
/// PYMS تراکنش را ایجاد و URL درگاه را برمی‌گرداند
/// </summary>
public async Task<PaymentInitiateResult> InitiatePaymentAsync(
PaymentRequest request,
CancellationToken cancellationToken = default)
{
try
{
// CMS مبالغ را به تومان نگه‌داری می‌کند
// PYMS مبلغ را به ریال می‌خواهد — تبدیل تومان به ریال
var amountInRials = (long)(request.Amount * 10);
var grpcRequest = new PaymentRequestRequest
{
MerchantId = _merchantId,
Amount = amountInRials,
CallbackUrl = request.CallbackUrl ?? string.Empty,
Description = request.Description ?? string.Empty,
OrderId = request.UserId.ToString(),
// نوع تراکنش: Sandbox برای تست، Real برای Production
Type = _useSandbox ? TransactionTypeEnum.Sandbox : TransactionTypeEnum.Real,
Currency = CurrencyEnum.Irt, // تومان
};
if (!string.IsNullOrWhiteSpace(request.Mobile))
grpcRequest.Mobile = request.Mobile;
_logger.LogInformation(
"PYMS payment request: Amount={AmountToman} Toman ({AmountRial} Rial), User={UserId}, Sandbox={Sandbox}",
request.Amount, amountInRials, request.UserId, _useSandbox);
var response = await _client.PaymentRequestAsync(grpcRequest, cancellationToken: cancellationToken);
if (!string.IsNullOrEmpty(response.PaymentGWUrl))
{
_logger.LogInformation(
"PYMS payment initiated successfully: GatewayUrl={Url}",
response.PaymentGWUrl);
// از URL درگاه، Authority را استخراج می‌کنیم (آخرین بخش URL)
var authority = ExtractAuthorityFromUrl(response.PaymentGWUrl);
return new PaymentInitiateResult
{
IsSuccess = true,
RefId = authority,
GatewayUrl = response.PaymentGWUrl
};
}
_logger.LogError("PYMS payment request failed: Empty gateway URL returned");
return new PaymentInitiateResult
{
IsSuccess = false,
ErrorMessage = "خطا در دریافت آدرس درگاه از PYMS"
};
}
catch (Grpc.Core.RpcException ex)
{
_logger.LogError(ex, "PYMS gRPC error in InitiatePayment: Status={Status}, Detail={Detail}",
ex.StatusCode, ex.Status.Detail);
return new PaymentInitiateResult
{
IsSuccess = false,
ErrorMessage = $"خطا در ارتباط با سرویس پرداخت: {ex.Status.Detail}"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "PYMS InitiatePayment exception");
return new PaymentInitiateResult
{
IsSuccess = false,
ErrorMessage = $"خطا در ارتباط با سرویس پرداخت: {ex.Message}"
};
}
}
/// <summary>
/// تأیید پرداخت بدون مبلغ — PYMS خودش مبلغ را از تراکنش ذخیره‌شده می‌خواند
/// </summary>
public async Task<PaymentVerificationResult> VerifyPaymentAsync(
string refId,
string verificationToken,
CancellationToken cancellationToken = default)
{
return await VerifyPaymentInternalAsync(refId, verificationToken, cancellationToken);
}
/// <summary>
/// تأیید پرداخت با مبلغ — PYMS خودش verify را انجام می‌دهد
/// refId = Authority, verificationToken = Status (OK/NOK)
/// </summary>
public async Task<PaymentVerificationResult> VerifyPaymentAsync(
string refId,
string verificationToken,
decimal amountInToman,
CancellationToken cancellationToken = default)
{
return await VerifyPaymentInternalAsync(refId, verificationToken, cancellationToken);
}
private async Task<PaymentVerificationResult> VerifyPaymentInternalAsync(
string refId,
string verificationToken,
CancellationToken cancellationToken)
{
try
{
// اگر کاربر لغو کرده
if (!string.Equals(verificationToken, "OK", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Payment cancelled by user: Authority={Authority}", refId);
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = "پرداخت توسط کاربر لغو شد"
};
}
var grpcRequest = new PaymentVerificationRequest
{
Authority = refId,
Status = verificationToken
};
_logger.LogInformation("PYMS verify request: Authority={Authority}, Status={Status}",
refId, verificationToken);
var response = await _client.PaymentVerificationAsync(grpcRequest, cancellationToken: cancellationToken);
if (response.PaymentStatus)
{
_logger.LogInformation(
"PYMS payment verified: Id={Id}, RefId={RefId}, OrderId={OrderId}, StatusCode={StatusCode}",
response.Id, response.RefId, response.OrderId, response.VerificationStatusCode);
return new PaymentVerificationResult
{
IsSuccess = true,
RefId = refId,
TrackingCode = response.RefId,
Amount = 0, // مبلغ از DB خوانده می‌شود
Message = response.Message ?? "تراکنش موفق"
};
}
_logger.LogError(
"PYMS verify failed: Authority={Authority}, StatusCode={StatusCode}, Message={Message}",
refId, response.VerificationStatusCode, response.Message);
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = response.Message ?? "تأیید پرداخت ناموفق"
};
}
catch (Grpc.Core.RpcException ex)
{
_logger.LogError(ex, "PYMS gRPC error in VerifyPayment: Status={Status}, Detail={Detail}",
ex.StatusCode, ex.Status.Detail);
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = $"خطا در تأیید تراکنش: {ex.Status.Detail}"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "PYMS VerifyPayment exception: Authority={Authority}", refId);
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = $"خطا در تأیید تراکنش: {ex.Message}"
};
}
}
/// <summary>
/// PYMS فعلاً قابلیت Payout ندارد
/// </summary>
public Task<PayoutResult> ProcessPayoutAsync(
PayoutRequest request,
CancellationToken cancellationToken = default)
{
_logger.LogWarning("PYMS does not support direct payout yet.");
return Task.FromResult(new PayoutResult
{
IsSuccess = false,
Message = "سرویس پرداخت (PYMS) فعلاً از قابلیت واریز مستقیم پشتیبانی نمی‌کند",
ProcessedAt = DateTime.UtcNow
});
}
/// <summary>
/// استخراج Authority از URL درگاه
/// مثال: https://sandbox.zarinpal.com/pg/StartPay/A00000000000000000000000000123456789 → A00000000000000000000000000123456789
/// </summary>
private static string ExtractAuthorityFromUrl(string gatewayUrl)
{
if (string.IsNullOrEmpty(gatewayUrl))
return string.Empty;
// Authority معمولاً آخرین بخش URL است
var uri = new Uri(gatewayUrl);
var segments = uri.Segments;
if (segments.Length > 0)
{
return segments[^1].TrimEnd('/');
}
return gatewayUrl;
}
public void Dispose()
{
_channel?.Dispose();
}
}
@@ -0,0 +1,358 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Infrastructure.Services.Payment;
/// <summary>
/// پیاده‌سازی درگاه پرداخت زرین‌پال
/// ساپورت Sandbox (تست) و Production
/// </summary>
public class ZarinPalPaymentService : IPaymentGatewayService
{
private readonly HttpClient _httpClient;
private readonly ILogger<ZarinPalPaymentService> _logger;
private readonly string _merchantId;
private readonly bool _useSandbox;
// آدرس‌های Production
private const string ProductionApiBase = "https://api.zarinpal.com";
private const string ProductionStartPayBase = "https://www.zarinpal.com";
// آدرس‌های Sandbox
private const string SandboxApiBase = "https://sandbox.zarinpal.com";
private const string SandboxStartPayBase = "https://sandbox.zarinpal.com";
// مسیرهای API (مشترک)
private const string RequestEndpoint = "/pg/v4/payment/request.json";
private const string VerifyEndpoint = "/pg/v4/payment/verify.json";
private const string StartPayPath = "/pg/StartPay/";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public ZarinPalPaymentService(
HttpClient httpClient,
IConfiguration configuration,
ILogger<ZarinPalPaymentService> logger)
{
_httpClient = httpClient;
_logger = logger;
_merchantId = configuration["ZarinPal:MerchantId"]
?? throw new InvalidOperationException("ZarinPal:MerchantId is not configured.");
_useSandbox = configuration.GetValue<bool>("ZarinPal:UseSandbox", true);
var apiBase = _useSandbox ? SandboxApiBase : ProductionApiBase;
_httpClient.BaseAddress = new Uri(apiBase);
_logger.LogInformation("ZarinPal payment service initialized. Mode: {Mode}",
_useSandbox ? "🧪 Sandbox" : "🏦 Production");
}
/// <summary>
/// مرحله ۱: ارسال درخواست پرداخت به زرین‌پال و دریافت Authority
/// </summary>
public async Task<PaymentInitiateResult> InitiatePaymentAsync(
PaymentRequest request,
CancellationToken cancellationToken = default)
{
try
{
// زرین‌پال مبلغ را به ریال می‌خواهد — تبدیل تومان به ریال
var amountInRials = (long)(request.Amount * 10);
var zarinPalRequest = new ZarinPalPaymentRequest
{
MerchantId = _merchantId,
Amount = amountInRials,
Description = request.Description,
CallbackUrl = request.CallbackUrl,
Metadata = new ZarinPalMetadata
{
Mobile = string.IsNullOrWhiteSpace(request.Mobile) ? null : request.Mobile
}
};
var jsonContent = JsonSerializer.Serialize(zarinPalRequest, JsonOptions);
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
_logger.LogInformation(
"ZarinPal payment request: Amount={AmountToman} Toman ({AmountRial} Rial), User={UserId}, Sandbox={Sandbox}",
request.Amount, amountInRials, request.UserId, _useSandbox);
var response = await _httpClient.PostAsync(RequestEndpoint, content, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogDebug("ZarinPal request response: {StatusCode} - {Body}",
response.StatusCode, responseBody);
var result = JsonSerializer.Deserialize<ZarinPalResponse>(responseBody, JsonOptions);
if (result?.Data?.Code == 100 && !string.IsNullOrEmpty(result.Data.Authority))
{
var startPayBase = _useSandbox ? SandboxStartPayBase : ProductionStartPayBase;
var gatewayUrl = $"{startPayBase}{StartPayPath}{result.Data.Authority}";
_logger.LogInformation(
"ZarinPal payment initiated successfully: Authority={Authority}, GatewayUrl={Url}",
result.Data.Authority, gatewayUrl);
return new PaymentInitiateResult
{
IsSuccess = true,
RefId = result.Data.Authority,
GatewayUrl = gatewayUrl
};
}
// خطا
var errorCode = result?.Errors?.Code ?? result?.Data?.Code ?? -1;
var errorMessage = result?.Errors?.Message ?? "خطای ناشناخته از زرین‌پال";
_logger.LogError(
"ZarinPal payment request failed: Code={Code}, Message={Message}",
errorCode, errorMessage);
return new PaymentInitiateResult
{
IsSuccess = false,
ErrorMessage = $"خطای درگاه زرین‌پال (کد {errorCode}): {errorMessage}"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "ZarinPal InitiatePayment exception");
return new PaymentInitiateResult
{
IsSuccess = false,
ErrorMessage = $"خطا در ارتباط با درگاه زرین‌پال: {ex.Message}"
};
}
}
/// <summary>
/// تأیید پرداخت بدون مبلغ — برای سازگاری با اینترفیس.
/// ⚠ زرین‌پال مبلغ را در Verify نیاز دارد. از overload با amount استفاده کنید.
/// </summary>
public Task<PaymentVerificationResult> VerifyPaymentAsync(
string refId,
string verificationToken,
CancellationToken cancellationToken = default)
{
_logger.LogWarning("ZarinPal VerifyPaymentAsync called without amount — verification may fail!");
return VerifyPaymentWithAmountAsync(refId, verificationToken, 0, cancellationToken);
}
/// <summary>
/// تأیید پرداخت با مبلغ — نسخه اصلی برای زرین‌پال
/// refId = Authority، verificationToken = Status (OK/NOK)، amountInToman = مبلغ به تومان
/// </summary>
public Task<PaymentVerificationResult> VerifyPaymentAsync(
string refId,
string verificationToken,
decimal amountInToman,
CancellationToken cancellationToken = default)
{
return VerifyPaymentWithAmountAsync(refId, verificationToken, amountInToman, cancellationToken);
}
private async Task<PaymentVerificationResult> VerifyPaymentWithAmountAsync(
string refId,
string verificationToken,
decimal amountInToman,
CancellationToken cancellationToken)
{
try
{
// verificationToken باید "OK" باشد — در غیر اینصورت کاربر لغو کرده
if (!string.Equals(verificationToken, "OK", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("ZarinPal payment cancelled by user: Authority={Authority}", refId);
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = "پرداخت توسط کاربر لغو شد"
};
}
// تبدیل تومان → ریال (×۱۰)
var amountInRials = (long)(amountInToman * 10);
var verifyRequest = new ZarinPalVerifyRequest
{
MerchantId = _merchantId,
Authority = refId,
Amount = amountInRials
};
var jsonContent = JsonSerializer.Serialize(verifyRequest, JsonOptions);
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
_logger.LogInformation("ZarinPal verify request: Authority={Authority}, Amount={Amount} Rial",
refId, amountInRials);
var response = await _httpClient.PostAsync(VerifyEndpoint, content, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogDebug("ZarinPal verify response: {StatusCode} - {Body}",
response.StatusCode, responseBody);
var result = JsonSerializer.Deserialize<ZarinPalResponse>(responseBody, JsonOptions);
// code 100 = موفق | code 101 = قبلاً تأیید شده
if (result?.Data?.Code is 100 or 101)
{
_logger.LogInformation(
"ZarinPal payment verified: Authority={Authority}, RefId={RefId}, CardPan={CardPan}",
refId, result.Data.RefId, result.Data.CardPan);
return new PaymentVerificationResult
{
IsSuccess = true,
RefId = refId,
TrackingCode = result.Data.RefId?.ToString(),
Amount = (result.Data.Amount ?? 0) / 10m, // ریال → تومان
Message = result.Data.Code == 101
? "تراکنش قبلاً تأیید شده"
: "تراکنش موفق"
};
}
var errorCode = result?.Errors?.Code ?? result?.Data?.Code ?? -1;
var errorMessage = result?.Errors?.Message ?? "تأیید تراکنش ناموفق";
_logger.LogError(
"ZarinPal verify failed: Authority={Authority}, Code={Code}, Message={Message}",
refId, errorCode, errorMessage);
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = $"تأیید پرداخت ناموفق (کد {errorCode}): {errorMessage}"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "ZarinPal VerifyPayment exception: Authority={Authority}", refId);
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = $"خطا در تأیید تراکنش: {ex.Message}"
};
}
}
/// <summary>
/// زرین‌پال Payout مستقیم ندارد — این متد NotSupported برمی‌گرداند
/// برای Payout باید از سرویس دیگری (مثل دایا) استفاده شود
/// </summary>
public Task<PayoutResult> ProcessPayoutAsync(
PayoutRequest request,
CancellationToken cancellationToken = default)
{
_logger.LogWarning("ZarinPal does not support direct payout. Use a different provider for payouts.");
return Task.FromResult(new PayoutResult
{
IsSuccess = false,
Message = "درگاه زرین‌پال از قابلیت واریز مستقیم پشتیبانی نمی‌کند",
ProcessedAt = DateTime.UtcNow
});
}
// ── ZarinPal Request/Response DTOs ──
private class ZarinPalPaymentRequest
{
public string MerchantId { get; set; } = string.Empty;
public long Amount { get; set; }
public string Description { get; set; } = string.Empty;
public string CallbackUrl { get; set; } = string.Empty;
public ZarinPalMetadata? Metadata { get; set; }
}
private class ZarinPalMetadata
{
public string? Mobile { get; set; }
public string? Email { get; set; }
}
private class ZarinPalVerifyRequest
{
public string MerchantId { get; set; } = string.Empty;
public long Amount { get; set; }
public string Authority { get; set; } = string.Empty;
}
private class ZarinPalResponse
{
public ZarinPalResponseData? Data { get; set; }
[JsonConverter(typeof(ZarinPalErrorsConverter))]
public ZarinPalResponseErrors? Errors { get; set; }
}
private class ZarinPalResponseData
{
public int? Code { get; set; }
public string? Message { get; set; }
public string? Authority { get; set; }
public long? RefId { get; set; }
public long? Amount { get; set; }
public string? CardPan { get; set; }
public string? CardHash { get; set; }
public string? FeeType { get; set; }
public long? Fee { get; set; }
}
private class ZarinPalResponseErrors
{
public int? Code { get; set; }
public string? Message { get; set; }
}
/// <summary>
/// ZarinPal returns errors as [] (empty array) when no error, or as {...} object when there's an error.
/// This converter handles both cases.
/// </summary>
private class ZarinPalErrorsConverter : JsonConverter<ZarinPalResponseErrors?>
{
public override ZarinPalResponseErrors? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.StartArray)
{
// Skip the empty array []
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) { }
return null;
}
if (reader.TokenType == JsonTokenType.StartObject)
{
return JsonSerializer.Deserialize<ZarinPalResponseErrors>(ref reader);
}
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}
reader.Skip();
return null;
}
public override void Write(Utf8JsonWriter writer, ZarinPalResponseErrors? value, JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, value, options);
}
}
}