252 lines
9.1 KiB
Markdown
252 lines
9.1 KiB
Markdown
# 📁 معماری مدیریت فایل و تصاویر — CMS
|
||
|
||
> **تاریخ:** ۱۴۰۴/۱۱/۲۸ (February 17, 2026)
|
||
> **وضعیت:** ✅ عملیاتی
|
||
> **Build:** 0 Error (هر ۳ پروژه) ✅
|
||
|
||
---
|
||
|
||
## ۱. پیشزمینه
|
||
|
||
سیستم قبلی از **FMS (File Management Service)** در آدرس `https://dl.afrino.co` استفاده میکرد که غیرقابل دسترس/ناسازگار شده بود. در چندین فاز، معماری فایلها به صورت کامل بازنویسی شد:
|
||
|
||
| فاز | شرح | وضعیت |
|
||
|-----|------|-------|
|
||
| ۱. حذف FMS | حذف کامل ۳ فایل مرده FMS | ✅ |
|
||
| ۲. حالت base64 | ذخیره data URI مستقیم در DB | ✅ (بازنشسته) |
|
||
| ۳. ذخیره دیسکی | فایل در دیسک + مسیر در DB + تبدیل به base64 هنگام serve | ✅ |
|
||
| ۴. **سرو HTTP عمومی** | **اندپوینت `/uploads/{path}` + Fallback FMS** | **✅ جدید** |
|
||
|
||
---
|
||
|
||
## ۲. معماری نهایی
|
||
|
||
```
|
||
BackOffice (Blazor WASM)
|
||
│
|
||
│ MudFileUpload → IBrowserFile → byte[] → gRPC ImageFileModel
|
||
│
|
||
▼
|
||
CMS gRPC Service
|
||
│
|
||
│ proto ImageFileModel → Command.ImageFileBytes
|
||
│
|
||
▼
|
||
MediatR Handler
|
||
│
|
||
│ IFileManager.UploadImageAsync(folder, bytes, mime, name)
|
||
│
|
||
▼
|
||
LocalFileManager
|
||
│
|
||
├─ Main Image → Uploads/Images/{folder}/{guid}.jpg (1200×1200, JPEG Q75)
|
||
├─ Thumbnail → Uploads/Images/{folder}/{guid}_thumb.jpg (300×300, JPEG Q75)
|
||
│
|
||
│ Returns: { Main.Path, Thumbnail.Path } (relative paths stored in DB)
|
||
│
|
||
▼
|
||
ImagePathResolverInterceptor (gRPC response)
|
||
│
|
||
│ Walks all response fields → reads file from disk → data:{mime};base64,{bytes}
|
||
│
|
||
▼
|
||
BackOffice / FrontOffice ← receives base64 data URI directly in proto fields
|
||
```
|
||
|
||
---
|
||
|
||
## ۳. اجزای کلیدی
|
||
|
||
### ۳.۱ `IFileManager` — Interface
|
||
|
||
**مسیر:** `Application/Common/FileManager/IFileManager.cs`
|
||
|
||
```csharp
|
||
public interface IFileManager
|
||
{
|
||
Task<UploadResult> UploadAsync(string folder, byte[] file, string mime, string? fileName, CancellationToken ct);
|
||
Task<ImageUploadResult> UploadImageAsync(string folder, byte[] file, string mime, string? fileName, CancellationToken ct);
|
||
Task DeleteAsync(string path, CancellationToken ct);
|
||
string? ResolveImageUrl(string? path);
|
||
}
|
||
```
|
||
|
||
- **`UploadAsync`** — آپلود فایل خام
|
||
- **`UploadImageAsync`** — بهینهسازی + ساخت thumbnail خودکار (SixLabors.ImageSharp)
|
||
- **`ResolveImageUrl`** — تبدیل مسیر نسبی به data URI (base64)
|
||
|
||
### ۳.۲ `LocalFileManager` — پیادهسازی
|
||
|
||
**مسیر:** `Infrastructure/Services/LocalFileManager.cs`
|
||
|
||
| ویژگی | مقدار |
|
||
|-------|-------|
|
||
| ریشه آپلود | `FileStorage:UploadPath` یا `AppContext.BaseDirectory/Uploads` |
|
||
| فرمت تصویر اصلی | JPEG, Quality 75, حداکثر 1200×1200 |
|
||
| فرمت thumbnail | JPEG, Quality 75, حداکثر 300×300 |
|
||
| نامگذاری فایل | `{Guid}.jpg` + `{Guid}_thumb.jpg` |
|
||
| DI Registration | `services.AddSingleton<IFileManager, LocalFileManager>()` |
|
||
|
||
### ۳.۳ `ImagePathResolverInterceptor` — gRPC Interceptor
|
||
|
||
**مسیر:** `WebApi/Interceptors/ImagePathResolverInterceptor.cs`
|
||
|
||
اینترسپتور **خودکار** تمام فیلدهای تصویری را در responseهای gRPC پیدا کرده و مسیر نسبی را به data URI تبدیل میکند.
|
||
|
||
**فیلدهای شناساییشده:**
|
||
- `image_path`, `thumbnail_path`, `image_thumbnail_path`
|
||
- `featured_image_path`, `featured_image_thumbnail_path`
|
||
- `hero_image_path`, `product_thumbnail_path`
|
||
- `avatar_path`, `avatar_url`
|
||
|
||
**قابلیتها:**
|
||
- Walk بازگشتی پیامهای proto
|
||
- پشتیبانی از `string` ساده و `Google.Protobuf.WellKnownTypes.StringValue`
|
||
- پشتیبانی از فیلدهای `repeated` (collectionهای تو در تو)
|
||
- اگر مقدار `data:` یا `http` باشد → رد میشود (تبدیل نمیشود)
|
||
|
||
### ۳.۴ `LoggingBehaviour` — پاکسازی لاگ
|
||
|
||
**مسیر:** `WebApi/Common/Behaviours/LoggingBehaviour.cs`
|
||
|
||
- فرمت لاگ: `JsonFormatter.Default.Format()` به جای `{@Request}`
|
||
- پاکسازی فیلدهای باینری با regex (`File`, `ImageFile`, `image_file`, `file`)
|
||
- محدودیت طول لاگ: حداکثر 2000 کاراکتر
|
||
|
||
### ۳.۵ `UploadsController` — سرو عمومی فایلها (HTTP) 🆕
|
||
|
||
**مسیر:** `WebApi/Controllers/UploadsController.cs`
|
||
|
||
اندپوینت عمومی REST برای سرو مستقیم تصاویر بدون نیاز به base64. مناسب برای بارگذاری تصاویر در تگ `<img>` و کاهش پهنای باند.
|
||
|
||
| ویژگی | مقدار |
|
||
|-------|-------|
|
||
| مسیر | `GET /uploads/{**path}` |
|
||
| احراز هویت | `[AllowAnonymous]` — عمومی |
|
||
| کش مرورگر | `ResponseCache 86400` ثانیه (۲۴ ساعت) |
|
||
| Content-Type | تشخیص خودکار از پسوند فایل (`FileExtensionContentTypeProvider`) |
|
||
| Range Requests | ✅ فعال (`enableRangeProcessing: true`) |
|
||
| محافظت مسیر | جلوگیری از path traversal (`..`, `\`, `Path.GetFullPath` validation) |
|
||
|
||
**FMS Fallback:**
|
||
اگر فایل محلی وجود نداشته باشد و تنظیم `FMS:Address` پر باشد:
|
||
1. فایل از `{FMS:Address}/{relativePath}` دانلود میشود
|
||
2. Content-Type بررسی میشود (فقط `image/*` و `application/pdf` مجاز)
|
||
3. فایل روی دیسک محلی ذخیره و کش میشود
|
||
4. سپس فایل محلی سرو میشود
|
||
|
||
```
|
||
Client → GET /uploads/Images/BlogPosts/abc.jpg
|
||
│
|
||
├─ فایل محلی وجود دارد? → سرو مستقیم از دیسک
|
||
│
|
||
└─ فایل محلی وجود ندارد?
|
||
└─ FMS:Address تنظیم شده?
|
||
├─ بله → دانلود از dl.afrino.co → ذخیره محلی → سرو
|
||
└─ خیر → 404 Not Found
|
||
```
|
||
|
||
**وابستگیها:**
|
||
- `IHttpClientFactory` با named client `"FMS"` (timeout: 30 ثانیه)
|
||
- ثبت در `Program.cs`: `builder.Services.AddHttpClient("FMS", ...)`
|
||
|
||
---
|
||
|
||
## ۴. Proto Messages — ImageFileModel
|
||
|
||
هر حوزه (DiscountProduct, BlogPost, SitePage) پیام مستقل `ImageFileModel` خود را دارد:
|
||
|
||
### DiscountProduct
|
||
```protobuf
|
||
message ImageFileModel {
|
||
bytes file = 1;
|
||
string mime = 2;
|
||
string file_name = 3;
|
||
}
|
||
```
|
||
**استفاده در:** `CreateDiscountProductRequest`, `UpdateDiscountProductRequest`
|
||
|
||
### BlogPost
|
||
```protobuf
|
||
message BlogImageFileModel {
|
||
bytes file = 1;
|
||
string mime = 2;
|
||
string file_name = 3;
|
||
}
|
||
```
|
||
**استفاده در:** `CreateBlogPostRequest`, `UpdateBlogPostRequest`
|
||
|
||
### SitePage
|
||
```protobuf
|
||
message SitePageImageFileModel {
|
||
bytes file = 1;
|
||
string mime = 2;
|
||
string file_name = 3;
|
||
}
|
||
```
|
||
**استفاده در:** `UpdateSitePageRequest`, `CreateSitePageSectionRequest`, `UpdateSitePageSectionRequest`
|
||
|
||
---
|
||
|
||
## ۵. جریان آپلود تصویر (مثال: BlogPost)
|
||
|
||
```
|
||
1. کاربر در BackOffice → MudFileUpload → انتخاب فایل
|
||
2. BlogPostEditDialog.OnImageSelected()
|
||
→ IBrowserFile.OpenReadStream() → byte[] + ContentType + FileName
|
||
→ پیشنمایش base64 در UI
|
||
3. Submit → BlogPostEditDto { ImageFile = bytes, ImageMime, ImageFileName }
|
||
4. BlogPostService.CreateAsync()
|
||
→ BlogImageFileModel { File = ByteString.CopyFrom(bytes), Mime, FileName }
|
||
→ gRPC CreateBlogPostRequest
|
||
5. CMS BlogPostService (gRPC) → CreateBlogPostCommand
|
||
{ ImageFileBytes = request.ImageFile.File.ToByteArray(), ... }
|
||
6. CreateBlogPostCommandHandler.Handle()
|
||
→ _fileManager.UploadImageAsync("Images/BlogPosts", bytes, mime, name)
|
||
→ post.FeaturedImagePath = result.Main.Path
|
||
→ post.FeaturedImageThumbnailPath = result.Thumbnail.Path
|
||
7. Response → ImagePathResolverInterceptor
|
||
→ featured_image_path → data:image/jpeg;base64,...
|
||
→ featured_image_thumbnail_path → data:image/jpeg;base64,...
|
||
8. BackOffice / FrontOffice → نمایش مستقیم base64 data URI
|
||
```
|
||
|
||
---
|
||
|
||
## ۶. فایلهای حذفشده (کد مرده FMS)
|
||
|
||
| فایل | شرح |
|
||
|------|------|
|
||
| `Infrastructure/Services/FmsFileManager.cs` | پیادهسازی قدیمی FMS (HTTP upload) |
|
||
| `Application/Common/FileManager/FileManagementService.cs` | سرویس قدیمی مدیریت فایل |
|
||
| `Application/Common/FileManager/IFileManagementService.cs` | اینترفیس قدیمی |
|
||
|
||
---
|
||
|
||
## ۷. تنظیمات
|
||
|
||
### `appsettings.json` (CMS)
|
||
```json
|
||
{
|
||
"FileStorage": {
|
||
"UploadPath": "/app/Uploads"
|
||
}
|
||
}
|
||
```
|
||
|
||
### محدودیت حجم gRPC
|
||
```csharp
|
||
// Program.cs
|
||
services.AddGrpc(o => o.MaxReceiveMessageSize = 50 * 1024 * 1024); // 50MB
|
||
```
|
||
|
||
### FrontOffice — `UrlUtility.GetImageUrl()`
|
||
```csharp
|
||
public static string GetImageUrl(string? path)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(path)) return string.Empty;
|
||
if (path.StartsWith("data:") || path.StartsWith("http")) return path;
|
||
return $"{DownloadUrl?.TrimEnd('/')}/{path.TrimStart('/')}";
|
||
}
|
||
```
|