Refactor code structure for improved readability and maintainability

This commit is contained in:
masoodafar-web
2026-02-16 00:59:16 +03:30
parent 956a9ff6d6
commit ad31c8be97
10 changed files with 3388 additions and 3 deletions
+251
View File
@@ -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('/')}";
}
```
+187
View File
@@ -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) ✅
```