2502cbbda2
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)
261 lines
12 KiB
C#
261 lines
12 KiB
C#
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"
|
|
};
|
|
}
|
|
}
|