Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
# 📁 معماری مدیریت فایل و تصاویر — 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('/')}";
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,187 @@
|
||||
# 🔐 فیکس فلوی ثبتنام / ورود FrontOffice
|
||||
|
||||
**تاریخ:** بهمن ۱۴۰۴ (February 2026)
|
||||
|
||||
---
|
||||
|
||||
## 📋 خلاصه
|
||||
|
||||
بررسی کامل فلوی ثبتنام و ورود FrontOffice از UI تا دیتابیس انجام شد. **۳ باگ بحرانی** شناسایی و رفع شده:
|
||||
|
||||
| # | شدت | مشکل | فایل |
|
||||
|---|------|-------|------|
|
||||
| 1 | 🔴 بحرانی | کاربران جدید ثبتنام نمیشوند | `UserCQ/VerifyOtpToken/VerifyOtpTokenCommandHandler.cs` |
|
||||
| 2 | 🔴 بحرانی | امضای قرارداد همیشه شکست میخورد | `UserCQ/AcceptContract/AcceptContractCommandHandler.cs` |
|
||||
| 3 | 🟡 متوسط | منوی کناری وضعیت نادرست نشان میدهد | `FrontOffice.Main/Utilities/AuthService.cs` |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 باگ ۱ — کاربران جدید ثبتنام نمیشوند
|
||||
|
||||
### مشکل
|
||||
|
||||
FrontOffice از `UserContract.UserContractClient` (user.proto) استفاده میکند → `UserCQ/VerifyOtpTokenCommandHandler`. این handler وقتی کاربر یافت نمیشد فقط خطای **«کاربر یافت نشد»** برمیگرداند و کاربر جدید ایجاد **نمیکرد**.
|
||||
|
||||
لاجیک ایجاد کاربر (شامل: اعتبارسنجی کد معرف، درخت باینری، موقعیت شاخه) در `OtpTokenCQ/VerifyOtpTokenCommandHandler` بود — سرویسی که FrontOffice اصلاً از آن استفاده نمیکند.
|
||||
|
||||
### رفع
|
||||
|
||||
اضافه شدن لاجیک کامل ایجاد کاربر جدید به `UserCQ/VerifyOtpTokenCommandHandler`:
|
||||
|
||||
```
|
||||
if (user == null)
|
||||
{
|
||||
// ۱. اعتبارسنجی کد معرف (ParentReferralCode) — الزامی
|
||||
// ۲. بررسی وجود معرف و فعال بودن عضویت باشگاه
|
||||
// ۳. بررسی ظرفیت (حداکثر ۲ زیرمجموعه مستقیم)
|
||||
// ۴. تعیین شاخه (چپ اول، بعد راست)
|
||||
// ۵. ایجاد User + UserRole + UserWallet
|
||||
// ۶. رویدادهای دامنه: CreateNewUserEvent, CreateNewUserRoleEvent, CreateNewUserWalletEvent
|
||||
// ۷. بارگذاری مجدد کاربر با روابط کامل
|
||||
// ۸. تولید JWT token
|
||||
}
|
||||
```
|
||||
|
||||
### اعتبارسنجیها
|
||||
|
||||
| مرحله | شرط | پیام خطا |
|
||||
|-------|------|---------|
|
||||
| کد معرف | خالی یا null | «کد معرف الزامی است» |
|
||||
| معرف | وجود نداشته باشد | «معرف وجود ندارد» |
|
||||
| عضویت باشگاه | غیرفعال باشد | «لینک دعوت معرف فعال نیست» |
|
||||
| ظرفیت | بیش از ۱ فرزند | «ظرفیت معرف تکمیل است» |
|
||||
| شاخه | هر دو پُر باشند | «ظرفیت معرف تکمیل است» |
|
||||
|
||||
### فایل
|
||||
|
||||
`CMS/src/CMSMicroservice.Application/UserCQ/Commands/VerifyOtpToken/VerifyOtpTokenCommandHandler.cs`
|
||||
|
||||
---
|
||||
|
||||
## 🔴 باگ ۲ — امضای قرارداد همیشه شکست میخورد
|
||||
|
||||
### مشکل
|
||||
|
||||
`AcceptContractCommandHandler` از `_currentUserService.Username` برای پیدا کردن OTP و کاربر بر اساس شماره موبایل استفاده میکرد:
|
||||
|
||||
```csharp
|
||||
// ❌ قبل — Username = "{FirstName} {LastName}" نه شماره موبایل!
|
||||
var otpToken = await _context.OtpTokens
|
||||
.Where(x => x.Mobile == _currentUserService.Username ...)
|
||||
var user = await _context.Users
|
||||
.Where(x => x.Mobile == _currentUserService.Username ...)
|
||||
```
|
||||
|
||||
**`CurrentUserService.Username`** مقدار `ClaimTypes.Name` را برمیگرداند که در JWT به صورت `"{FirstName} {LastName}"` ذخیره شده — **نه شماره موبایل!**
|
||||
|
||||
### رفع
|
||||
|
||||
اول کاربر بر اساس `UserId` (از `ClaimTypes.NameIdentifier`) پیدا شود، سپس از `user.Mobile` برای جستجوی OTP استفاده شود:
|
||||
|
||||
```csharp
|
||||
// ✅ بعد — ابتدا کاربر از UserId پیدا شود
|
||||
var userId = long.Parse(_currentUserService.UserId);
|
||||
var user = await _context.Users
|
||||
.Where(x => x.Id == userId)...
|
||||
|
||||
var otpToken = await _context.OtpTokens
|
||||
.Where(x => x.Mobile == user.Mobile ...)
|
||||
```
|
||||
|
||||
### فایل
|
||||
|
||||
`CMS/src/CMSMicroservice.Application/UserCQ/Commands/AcceptContract/AcceptContractCommandHandler.cs`
|
||||
|
||||
---
|
||||
|
||||
## 🟡 باگ ۳ — `IsCompleteRegister()` داده قدیمی میخواند
|
||||
|
||||
### مشکل
|
||||
|
||||
متد sync در `AuthService`:
|
||||
|
||||
```csharp
|
||||
// ❌ قبل — GetAwaiter() بدون GetResult() عملیات async را اجرا نمیکند
|
||||
InitUserAuthInfo().GetAwaiter();
|
||||
```
|
||||
|
||||
`GetAwaiter()` فقط یک شیء awaiter برمیگرداند ولی عملیات را اجرا **نمیکند**. در نتیجه `_userAuthInfo` مقداردهی نمیشود و منوی کناری (`MainLayout.razor`) وضعیت نادرست نشان میدهد.
|
||||
|
||||
### رفع
|
||||
|
||||
```csharp
|
||||
// ✅ بعد — عملیات async را همگام اجرا میکند
|
||||
InitUserAuthInfo().GetAwaiter().GetResult();
|
||||
```
|
||||
|
||||
### محل استفاده
|
||||
|
||||
`MainLayout.razor` خطوط ۱۰۶ و ۱۰۹:
|
||||
```razor
|
||||
Disabled="@(!AuthService.IsCompleteRegister())"
|
||||
```
|
||||
|
||||
### فایل
|
||||
|
||||
`FrontOffice/src/FrontOffice.Main/Utilities/AuthService.cs`
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ معماری فلوی ثبتنام (بعد از فیکس)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FrontOffice (Blazor WASM) │
|
||||
│ │
|
||||
│ LoginPage → SendOtp → VerifyOtp(mobile, code, referral) │
|
||||
│ ↓ gRPC-Web │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ CMS Backend (gRPC) │
|
||||
│ │
|
||||
│ UserContract.VerifyOtpToken │
|
||||
│ ↓ │
|
||||
│ UserCQ/VerifyOtpTokenCommandHandler │
|
||||
│ │ │
|
||||
│ ├── OTP صحیح؟ → ❌ خطا │
|
||||
│ │ │
|
||||
│ ├── کاربر موجود؟ → ✅ تولید JWT Token │
|
||||
│ │ │
|
||||
│ └── کاربر جدید؟ │
|
||||
│ ├── اعتبارسنجی کد معرف │
|
||||
│ ├── بررسی ظرفیت درخت باینری │
|
||||
│ ├── ایجاد User + UserRole + UserWallet │
|
||||
│ ├── رویدادهای دامنه │
|
||||
│ └── تولید JWT Token │
|
||||
│ │
|
||||
│ بعد از ثبتنام → RegisterWizard: │
|
||||
│ Step 1: اطلاعات شخصی │
|
||||
│ Step 2: امضای قرارداد (AcceptContract + OTP) │
|
||||
│ Step 3: خرید پکیج │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Database │
|
||||
│ │
|
||||
│ Users ─── UserRoles ─── UserWallets │
|
||||
│ └── NetworkParentId, LegPosition (Binary Tree) │
|
||||
│ └── UserContracts, ClubMembership │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 فایلهای تغییر یافته
|
||||
|
||||
| فایل | تغییر |
|
||||
|------|-------|
|
||||
| `CMS/.../UserCQ/Commands/VerifyOtpToken/VerifyOtpTokenCommandHandler.cs` | اضافه شدن لاجیک ایجاد کاربر جدید (86→165 خط) |
|
||||
| `CMS/.../UserCQ/Commands/AcceptContract/AcceptContractCommandHandler.cs` | تغییر lookup از Username به UserId |
|
||||
| `FrontOffice/.../Utilities/AuthService.cs` | اضافه شدن `.GetResult()` به `GetAwaiter()` |
|
||||
|
||||
---
|
||||
|
||||
## ✅ بیلد
|
||||
|
||||
```
|
||||
CMS: 0 Error(s) ✅
|
||||
FrontOffice: 0 Error(s) ✅
|
||||
BackOffice: 0 Error(s) ✅
|
||||
```
|
||||
Reference in New Issue
Block a user