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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user