Files
docs/01-BUSINESS/club-membership-contract-system.md
T
masoodafar-web 002e99f6bf Implement Persian Date Conversion and Enhance User Network Information Service
- Added PersianDateTimeService for converting Gregorian dates to Persian format in the BackOffice frontend.
- Updated multiple frontend pages (Dashboard, UserPayouts, WorkerControl, UserNetworkInfo) to utilize the new Persian date service.
- Enhanced GetUserNetworkPositionDto with 28+ new fields for comprehensive user network data.
- Updated GetUserNetworkPositionQueryHandler to include new methods for calculating network statistics.
- Modified Protobuf messages to accommodate the new fields, increasing from 14 to 42.
- Refined week number calculation algorithm to ensure consistency across C# and SQL implementations.
- Created new CSV and Excel files for binary plan calculations.
- Ensured all changes are tested and validated for accuracy and performance.
2025-12-20 06:15:59 +03:30

1423 lines
45 KiB
Markdown

# Club Membership Contract System - سیستم قرارداد باشگاه مشتریان
**تاریخ ایجاد:** 2024-12-16
**وضعیت:** ✅ پیاده‌سازی شده
**اولویت:** 🔴 بسیار بالا
**مرتبط با:** [base-package-payment-system.md](./base-package-payment-system.md)
---
## 📋 فهرست
1. [خلاصه سیستم](#خلاصه-سیستم)
2. [Business Requirements](#business-requirements)
3. [Complete Flow](#complete-flow)
4. [CMS Layer](#cms-layer)
5. [BFF Layer](#bff-layer)
6. [Frontend Layer](#frontend-layer)
7. [OTP SMS Format](#otp-sms-format)
8. [Token Refresh Pattern](#token-refresh-pattern)
9. [Testing Checklist](#testing-checklist)
---
## 🎯 خلاصه سیستم
سیستم قرارداد باشگاه مشتریان یک **مدال غیرقابل بسته شدن** است که بعد از پرداخت موفق پکیج پایه، کاربر را ملزم به **امضای قرارداد** می‌کند تا بتواند:
1. عضویت باشگاه مشتریان فعال شود
2. لینک دعوت (Referral Link) نمایش داده شود
3. به امکانات کامل باشگاه مشتریان دسترسی داشته باشد
### ویژگی‌های کلیدی:
- ✅ Modal **غیرقابل بسته شدن** (کاربر نمی‌تواند Escape یا Click بیرون را استفاده کند)
-**OTP Verification** برای امنیت بالاتر
-**Automatic Token Refresh** بعد از امضای موفق
- ✅ ثبت قرارداد در دیتابیس با **HTML content** و **SignGuid**
---
## 📊 Business Requirements
### شرایط نمایش Modal:
```csharp
if (HasPurchasedPackage && !IsClubMemberActive)
{
// نمایش Modal قرارداد
}
```
- **HasPurchasedPackage**: `PackagePurchaseMethod != None` (پرداخت موفق انجام شده)
- **IsClubMemberActive**: `ClubMembership.IsActive = true` (قرارداد امضا شده)
### ContractType Enum:
```csharp
public enum ContractType
{
Main = 0, // قرارداد ثبت‌نام اولیه
ClubMembership = 1, // قرارداد باشگاه مشتریان
}
```
### OTP Configuration:
- **Purpose**: `signClubContract`
- **Expiry**: 120 seconds (2 minutes)
- **Code Length**: 6 digits
- **SMS Provider**: Kavenegar
### ClubMembership Activation Values:
```csharp
ClubMembership {
IsActive = true,
ActivatedAt = DateTime.Now,
InitialContribution = 56_000_000, // مبلغ اولیه
GiftValue = 25_200_000, // ارزش هدیه (45% از 56M)
PurchaseMethod = user.PackagePurchaseMethod
}
```
---
## 🔄 Complete Flow
```mermaid
sequenceDiagram
participant User
participant Frontend
participant BFF
participant CMS
participant SMS as Kavenegar
Note over User,SMS: 1️⃣ Payment Successful (قبلاً انجام شده)
User->>Frontend: ورود به صفحه Profile
Frontend->>Frontend: CheckAndShowClubContractModal()
alt HasPurchasedPackage && !IsClubMemberActive
Frontend->>User: نمایش Modal غیرقابل بسته شدن
User->>User: مطالعه قرارداد (HTML Content)
Note over User,SMS: 2️⃣ Request OTP
User->>Frontend: کلیک "درخواست کد تایید"
Frontend->>BFF: RequestClubContractOtp(SignGuid)
BFF->>CMS: CreateNewOtpToken(mobile, "signClubContract")
CMS-->>BFF: OTP Code (6 digits)
BFF->>SMS: Send SMS(mobile, code, signGuid, fullName)
SMS-->>User: پیامک با کد OTP
BFF-->>Frontend: Success
Frontend->>Frontend: شروع Timer (120 ثانیه)
Note over User,SMS: 3️⃣ Accept Contract
User->>Frontend: وارد کردن OTP Code
Frontend->>BFF: AcceptClubMembershipContract(OtpCode, SignGuid, ContractHtml)
BFF->>CMS: AcceptClubMembershipContract(UserId, OtpCode, SignGuid, ContractHtml)
CMS->>CMS: VerifyOtpAsync(mobile, "signClubContract", OtpCode)
alt OTP Invalid
CMS-->>BFF: Error: "کد وارد شده اشتباه است"
BFF-->>Frontend: Error
Frontend->>User: پیام خطا
else OTP Valid
CMS->>CMS: ثبت Contract (اگر وجود نداشته باشد)
CMS->>CMS: ثبت UserContract (SignGuid, ContractHtml)
CMS->>CMS: فعالسازی ClubMembership (IsActive=true)
CMS-->>BFF: Success
Note over User,SMS: 4️⃣ Token Refresh
BFF->>CMS: GetJwtToken(UserId)
CMS-->>BFF: New JWT Token
BFF-->>Frontend: Success + NewToken
Frontend->>Frontend: ذخیره Token در localStorage
Frontend->>Frontend: بستن Modal و Refresh صفحه
Frontend->>User: نمایش پیام موفقیت + لینک دعوت
end
end
```
---
## 💻 CMS Layer
### 📂 File Structure:
```
CMS/
src/CMSMicroservice.Domain/
Enums/
ContractType.cs ✅ Modified
src/CMSMicroservice.Application/
ClubMemberships/
Commands/
AcceptClubMembershipContract/
AcceptClubMembershipContractCommand.cs ✅ Created
AcceptClubMembershipContractCommandValidator.cs ✅ Created
AcceptClubMembershipContractCommandHandler.cs ✅ Created
Profiles/
ClubFeatureProfile.cs ✅ Modified
src/CMSMicroservice.Protobuf/
Protos/
clubmembership.proto ✅ Modified
src/CMSMicroservice.WebApi/
Services/
ClubMembershipService.cs ✅ Modified
```
---
### 1️⃣ ContractType.cs
```csharp
namespace CMSMicroservice.Domain.Enums;
/// <summary>
/// تعیین نوع قرارداد
/// </summary>
public enum ContractType
{
/// <summary>
/// قرارداد ثبت‌نام اولیه
/// </summary>
Main = 0,
/// <summary>
/// قرارداد باشگاه مشتریان
/// </summary>
ClubMembership = 1,
}
```
**تغییرات:** `CMS = 1``ClubMembership = 1`
---
### 2️⃣ AcceptClubMembershipContractCommand.cs
```csharp
namespace CMSMicroservice.Application.ClubMemberships.Commands.AcceptClubMembershipContract;
/// <summary>
/// Command برای پذیرش و امضای قرارداد باشگاه مشتریان
/// </summary>
public record AcceptClubMembershipContractCommand
{
/// <summary>
/// شناسه کاربر
/// </summary>
public required long UserId { get; init; }
/// <summary>
/// کد OTP ارسال شده به کاربر (6 رقمی)
/// </summary>
public required string OtpCode { get; init; }
/// <summary>
/// شناسه یکتای امضاء (GUID)
/// </summary>
public required string SignGuid { get; init; }
/// <summary>
/// محتوای HTML قرارداد برای ذخیره
/// </summary>
public required string ContractHtml { get; init; }
}
```
---
### 3️⃣ AcceptClubMembershipContractCommandValidator.cs
```csharp
using FluentValidation;
namespace CMSMicroservice.Application.ClubMemberships.Commands.AcceptClubMembershipContract;
public class AcceptClubMembershipContractCommandValidator
: AbstractValidator<AcceptClubMembershipContractCommand>
{
public AcceptClubMembershipContractCommandValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر نامعتبر است");
RuleFor(x => x.OtpCode)
.NotEmpty()
.WithMessage("کد تایید الزامی است")
.Length(6)
.WithMessage("کد تایید باید 6 رقمی باشد")
.Matches(@"^\d{6}$")
.WithMessage("کد تایید فقط باید شامل اعداد باشد");
RuleFor(x => x.SignGuid)
.NotEmpty()
.WithMessage("شناسه امضاء الزامی است")
.Must(guid => Guid.TryParse(guid, out _))
.WithMessage("شناسه امضاء نامعتبر است");
RuleFor(x => x.ContractHtml)
.NotEmpty()
.WithMessage("محتوای قرارداد الزامی است")
.MinimumLength(100)
.WithMessage("محتوای قرارداد نامعتبر است");
}
}
```
---
### 4️⃣ AcceptClubMembershipContractCommandHandler.cs
```csharp
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.ClubMemberships.Commands.AcceptClubMembershipContract;
public class AcceptClubMembershipContractCommandHandler
: IRequestHandler<AcceptClubMembershipContractCommand, bool>
{
private readonly IApplicationDbContext _context;
public AcceptClubMembershipContractCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<bool> Handle(
AcceptClubMembershipContractCommand request,
CancellationToken cancellationToken)
{
// 1️⃣ دریافت کاربر با ClubMembership
var user = await _context.Users
.Include(u => u.ClubMembership)
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
throw new Exception("کاربر یافت نشد");
// 2️⃣ بررسی پیش‌نیازها
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
throw new Exception("برای فعالسازی باشگاه مشتریان ابتدا باید پکیج پایه را خریداری کنید");
if (user.ClubMembership?.IsActive == true)
throw new Exception("باشگاه مشتریان شما قبلاً فعال شده است");
// 3️⃣ تایید OTP
var isOtpValid = await VerifyOtpAsync(
user.MobileNumber,
"signClubContract",
request.OtpCode,
cancellationToken);
if (!isOtpValid)
throw new Exception("کد وارد شده اشتباه است یا منقضی شده است");
// 4️⃣ ایجاد/دریافت Contract
var contract = await _context.Contracts
.FirstOrDefaultAsync(c => c.Type == ContractType.ClubMembership, cancellationToken);
if (contract == null)
{
// اگر Contract وجود نداشته باشد، ایجاد می‌کنیم
contract = new Contract
{
Type = ContractType.ClubMembership,
Title = "قرارداد باشگاه مشتریان کارابازار",
Description = "شرایط و ضوابط عضویت در باشگاه مشتریان",
IsActive = true,
CreatedAt = DateTime.Now
};
_context.Contracts.Add(contract);
await _context.SaveChangesAsync(cancellationToken);
}
// 5️⃣ ثبت UserContract (امضای کاربر)
var userContract = new UserContract
{
UserId = user.Id,
ContractId = contract.Id,
SignGuid = request.SignGuid,
SignedPdfFile = request.ContractHtml, // HTML content ذخیره می‌شود
IsAccepted = true,
SignedAt = DateTime.Now
};
_context.UserContracts.Add(userContract);
// 6️⃣ فعالسازی ClubMembership
if (user.ClubMembership == null)
{
user.ClubMembership = new ClubMembership
{
UserId = user.Id,
IsActive = true,
ActivatedAt = DateTime.Now,
InitialContribution = 56_000_000, // مبلغ پکیج پایه
GiftValue = 25_200_000, // 45% هدیه
PurchaseMethod = user.PackagePurchaseMethod
};
_context.ClubMemberships.Add(user.ClubMembership);
}
else
{
user.ClubMembership.IsActive = true;
user.ClubMembership.ActivatedAt = DateTime.Now;
user.ClubMembership.InitialContribution = 56_000_000;
user.ClubMembership.GiftValue = 25_200_000;
user.ClubMembership.PurchaseMethod = user.PackagePurchaseMethod;
}
await _context.SaveChangesAsync(cancellationToken);
return true;
}
/// <summary>
/// تایید کد OTP
/// </summary>
private async Task<bool> VerifyOtpAsync(
string mobile,
string purpose,
string code,
CancellationToken cancellationToken)
{
var otpToken = await _context.OtpTokens
.Where(o => o.Mobile == mobile
&& o.Purpose == purpose
&& o.Code == code
&& !o.IsUsed)
.OrderByDescending(o => o.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (otpToken == null)
return false;
// بررسی انقضا (120 ثانیه)
if ((DateTime.Now - otpToken.CreatedAt).TotalSeconds > 120)
return false;
// علامت‌گذاری به عنوان استفاده شده
otpToken.IsUsed = true;
await _context.SaveChangesAsync(cancellationToken);
return true;
}
}
```
**نکات کلیدی:**
- ✅ بررسی `PackagePurchaseMethod != None` (باید پکیج خریداری شده باشد)
- ✅ جلوگیری از امضای مجدد (`ClubMembership.IsActive == true`)
- ✅ تایید OTP با `VerifyOtpAsync` method
- ✅ ایجاد Contract اگر وجود نداشته باشد
- ✅ ثبت UserContract با SignGuid و HTML content
- ✅ فعالسازی ClubMembership با مقادیر مشخص شده
---
### 5️⃣ clubmembership.proto
```protobuf
syntax = "proto3";
option csharp_namespace = "CMSMicroservice.Protobuf";
package clubmembership;
service ClubMembershipContract {
// ... other RPCs ...
rpc AcceptClubMembershipContract(AcceptClubMembershipContractRequest)
returns (AcceptClubMembershipContractResponse);
}
message AcceptClubMembershipContractRequest {
int64 user_id = 1;
string otp_code = 2;
string sign_guid = 3;
string contract_html = 4;
}
message AcceptClubMembershipContractResponse {
bool success = 1;
string message = 2;
}
```
---
### 6️⃣ ClubMembershipService.cs
```csharp
public override async Task<AcceptClubMembershipContractResponse> AcceptClubMembershipContract(
AcceptClubMembershipContractRequest request,
ServerCallContext context)
{
try
{
var command = _mapper.Map<AcceptClubMembershipContractCommand>(request);
var result = await _mediator.Send(command);
return new AcceptClubMembershipContractResponse
{
Success = result,
Message = result ? "قرارداد با موفقیت امضا شد" : "خطا در امضای قرارداد"
};
}
catch (Exception ex)
{
return new AcceptClubMembershipContractResponse
{
Success = false,
Message = ex.Message
};
}
}
```
---
### 7️⃣ ClubFeatureProfile.cs
```csharp
using CMSMicroservice.Application.ClubMemberships.Commands.AcceptClubMembershipContract;
using CMSMicroservice.Protobuf;
using Mapster;
namespace CMSMicroservice.Application.Profiles;
public class ClubFeatureProfile : IRegister
{
public void Register(TypeAdapterConfig config)
{
// ... other mappings ...
config.NewConfig<AcceptClubMembershipContractRequest, AcceptClubMembershipContractCommand>()
.Map(dest => dest.UserId, src => src.UserId)
.Map(dest => dest.OtpCode, src => src.OtpCode)
.Map(dest => dest.SignGuid, src => src.SignGuid)
.Map(dest => dest.ContractHtml, src => src.ContractHtml);
}
}
```
---
## 🔌 BFF Layer
### 📂 File Structure:
```
FrontOffice.BFF/
src/FrontOffice.BFF.Domain/
(No changes - using CMS entities)
src/FrontOffice.BFF.Application/
ClubMemberships/
Commands/
RequestClubContractOtp/
RequestClubContractOtpCommand.cs ✅ Created
RequestClubContractOtpCommandValidator.cs ✅ Created
RequestClubContractOtpCommandHandler.cs ✅ Created (با IKavenegarService)
AcceptClubMembershipContract/
AcceptClubMembershipContractCommand.cs ✅ Created
AcceptClubMembershipContractCommandValidator.cs ✅ Created
AcceptClubMembershipContractCommandHandler.cs ✅ Created
Profiles/
ClubMembershipProfile.cs ✅ Modified
src/Protobufs/
clubmembership.proto ✅ Modified
src/FrontOffice.BFF.WebApi/
Services/
ClubMembershipGrpcService.cs ✅ Modified
```
---
### 1️⃣ RequestClubContractOtpCommand.cs
```csharp
using MediatR;
namespace FrontOffice.BFF.Application.ClubMemberships.Commands.RequestClubContractOtp;
/// <summary>
/// Command برای درخواست OTP برای امضای قرارداد باشگاه مشتریان
/// </summary>
public record RequestClubContractOtpCommand : IRequest<bool>
{
/// <summary>
/// شناسه یکتای امضاء (GUID) - برای ارسال در پیامک
/// </summary>
public required string SignGuid { get; init; }
}
```
---
### 2️⃣ RequestClubContractOtpCommandValidator.cs
```csharp
using FluentValidation;
namespace FrontOffice.BFF.Application.ClubMemberships.Commands.RequestClubContractOtp;
public class RequestClubContractOtpCommandValidator
: AbstractValidator<RequestClubContractOtpCommand>
{
public RequestClubContractOtpCommandValidator()
{
RuleFor(x => x.SignGuid)
.NotEmpty()
.WithMessage("شناسه امضاء الزامی است")
.Must(guid => Guid.TryParse(guid, out _))
.WithMessage("شناسه امضاء نامعتبر است");
}
}
```
---
### 3️⃣ RequestClubContractOtpCommandHandler.cs
```csharp
using System.Text;
using FrontOffice.BFF.Application.Common.Interfaces;
using MediatR;
using OtpService.Protobuf;
namespace FrontOffice.BFF.Application.ClubMemberships.Commands.RequestClubContractOtp;
public class RequestClubContractOtpCommandHandler
: IRequestHandler<RequestClubContractOtpCommand, bool>
{
private readonly IApplicationContractContext _context;
private readonly IKavenegarService _kavenegarService;
private readonly ICurrentUserService _currentUserService;
public RequestClubContractOtpCommandHandler(
IApplicationContractContext context,
IKavenegarService kavenegarService,
ICurrentUserService currentUserService)
{
_context = context;
_kavenegarService = kavenegarService;
_currentUserService = currentUserService;
}
public async Task<bool> Handle(
RequestClubContractOtpCommand request,
CancellationToken cancellationToken)
{
// 1️⃣ دریافت شماره موبایل از CurrentUserService
var mobileNumber = _currentUserService.MobileNumber;
if (string.IsNullOrEmpty(mobileNumber))
throw new Exception("شماره موبایل کاربر یافت نشد");
// 2️⃣ فراخوانی CMS برای ایجاد OTP
var otpResponse = await _context.OtpToken.CreateNewOtpTokenAsync(
new CreateNewOtpTokenRequest
{
Mobile = mobileNumber,
Purpose = "signClubContract"
},
cancellationToken: cancellationToken);
if (!otpResponse.Success || string.IsNullOrWhiteSpace(otpResponse.Code))
throw new Exception("خطا در ارسال کد تایید");
// 3️⃣ ارسال پیامک با Kavenegar
var fullName = $"{_currentUserService.FirstName} {_currentUserService.LastName}".Trim();
await _kavenegarService.Send(
mobile: mobileNumber,
new StringBuilder("سلام ")
.Append(fullName)
.AppendLine(" عزیز")
.Append("کد یک بار مصرف برای تایید قرارداد باشگاه مشتریان: ")
.AppendLine(otpResponse.Code)
.AppendLine("شناسه امضاء: ")
.AppendLine(request.SignGuid)
.AppendLine("کارابازار")
.ToString());
return true;
}
}
```
**نکات کلیدی:**
- ✅ استفاده از `IKavenegarService` برای ارسال پیامک (مشابه `CreateNewOtpTokenCommandHandler`)
- ✅ دریافت `MobileNumber` از `ICurrentUserService` (از JWT Token)
- ✅ Purpose: `signClubContract`
- ✅ ارسال `SignGuid` در پیامک برای ردیابی
- ✅ Format پیامک شامل: نام کاربر، کد OTP، شناسه امضا، نام شرکت
**SMS Format Example:**
```
سلام علی عزیز
کد یک بار مصرف برای تایید قرارداد باشگاه مشتریان: 123456
شناسه امضاء: a1b2c3d4-e5f6-7890-abcd-ef1234567890
کارابازار
```
---
### 4️⃣ AcceptClubMembershipContractCommand.cs
```csharp
using MediatR;
namespace FrontOffice.BFF.Application.ClubMemberships.Commands.AcceptClubMembershipContract;
/// <summary>
/// Command برای پذیرش و امضای قرارداد باشگاه مشتریان
/// </summary>
public record AcceptClubMembershipContractCommand : IRequest<string?>
{
/// <summary>
/// کد OTP ارسال شده به کاربر (6 رقمی)
/// </summary>
public required string OtpCode { get; init; }
/// <summary>
/// شناسه یکتای امضاء (GUID)
/// </summary>
public required string SignGuid { get; init; }
/// <summary>
/// محتوای HTML قرارداد برای ذخیره
/// </summary>
public required string ContractHtml { get; init; }
}
```
**Return Type:** `string?` - JWT Token جدید (اگر موفق بود)
---
### 5️⃣ AcceptClubMembershipContractCommandValidator.cs
```csharp
using FluentValidation;
namespace FrontOffice.BFF.Application.ClubMemberships.Commands.AcceptClubMembershipContract;
public class AcceptClubMembershipContractCommandValidator
: AbstractValidator<AcceptClubMembershipContractCommand>
{
public AcceptClubMembershipContractCommandValidator()
{
RuleFor(x => x.OtpCode)
.NotEmpty()
.WithMessage("کد تایید الزامی است")
.Length(6)
.WithMessage("کد تایید باید 6 رقمی باشد")
.Matches(@"^\d{6}$")
.WithMessage("کد تایید فقط باید شامل اعداد باشد");
RuleFor(x => x.SignGuid)
.NotEmpty()
.WithMessage("شناسه امضاء الزامی است")
.Must(guid => Guid.TryParse(guid, out _))
.WithMessage("شناسه امضاء نامعتبر است");
RuleFor(x => x.ContractHtml)
.NotEmpty()
.WithMessage("محتوای قرارداد الزامی است")
.MinimumLength(100)
.WithMessage("محتوای قرارداد نامعتبر است");
}
}
```
---
### 6️⃣ AcceptClubMembershipContractCommandHandler.cs
```csharp
using CMSMicroservice.Protobuf;
using FrontOffice.BFF.Application.Common.Interfaces;
using MediatR;
using User.Protobuf;
namespace FrontOffice.BFF.Application.ClubMemberships.Commands.AcceptClubMembershipContract;
public class AcceptClubMembershipContractCommandHandler
: IRequestHandler<AcceptClubMembershipContractCommand, string?>
{
private readonly IApplicationContractContext _context;
private readonly ICurrentUserService _currentUserService;
public AcceptClubMembershipContractCommandHandler(
IApplicationContractContext context,
ICurrentUserService currentUserService)
{
_context = context;
_currentUserService = currentUserService;
}
public async Task<string?> Handle(
AcceptClubMembershipContractCommand request,
CancellationToken cancellationToken)
{
var userId = _currentUserService.UserId
?? throw new Exception("کاربر احراز هویت نشده است");
// 1️⃣ فراخوانی CMS برای امضای قرارداد
var cmsResponse = await _context.ClubMemberships.AcceptClubMembershipContractAsync(
new AcceptClubMembershipContractRequest
{
UserId = userId,
OtpCode = request.OtpCode,
SignGuid = request.SignGuid,
ContractHtml = request.ContractHtml
},
cancellationToken: cancellationToken);
if (!cmsResponse.Success)
throw new Exception(cmsResponse.Message ?? "خطا در امضای قرارداد");
// 2️⃣ دریافت JWT Token جدید
var tokenResponse = await _context.User.GetJwtTokenAsync(
new GetJwtTokenRequest { Id = userId },
cancellationToken: cancellationToken);
return tokenResponse?.Token;
}
}
```
**نکات کلیدی:**
- ✅ دریافت `UserId` از `ICurrentUserService`
- ✅ فراخوانی CMS.AcceptClubMembershipContract
-**Automatic Token Refresh** بعد از موفقیت
- ✅ Return کردن token جدید به Frontend
---
### 7️⃣ clubmembership.proto (BFF)
```protobuf
syntax = "proto3";
option csharp_namespace = "FrontOffice.BFF.ClubMembership.Protobuf";
package clubmembership;
service ClubMembership {
// ... other RPCs ...
rpc RequestClubContractOtp(RequestClubContractOtpRequest)
returns (RequestClubContractOtpResponse);
rpc AcceptClubMembershipContract(AcceptClubMembershipContractRequest)
returns (AcceptClubMembershipContractResponse);
}
message RequestClubContractOtpRequest {
string sign_guid = 1;
}
message RequestClubContractOtpResponse {
bool success = 1;
string message = 2;
}
message AcceptClubMembershipContractRequest {
string otp_code = 1;
string sign_guid = 2;
string contract_html = 3;
}
message AcceptClubMembershipContractResponse {
bool success = 1;
string message = 2;
string new_token = 3; // JWT Token جدید
}
```
---
### 8️⃣ ClubMembershipGrpcService.cs
```csharp
public override async Task<RequestClubContractOtpResponse> RequestClubContractOtp(
RequestClubContractOtpRequest request,
ServerCallContext context)
{
try
{
var command = _mapper.Map<RequestClubContractOtpCommand>(request);
var result = await _mediator.Send(command);
return new RequestClubContractOtpResponse
{
Success = result,
Message = result ? "کد تایید ارسال شد" : "خطا در ارسال کد تایید"
};
}
catch (Exception ex)
{
return new RequestClubContractOtpResponse
{
Success = false,
Message = ex.Message
};
}
}
public override async Task<AcceptClubMembershipContractResponse> AcceptClubMembershipContract(
AcceptClubMembershipContractRequest request,
ServerCallContext context)
{
try
{
var command = _mapper.Map<AcceptClubMembershipContractCommand>(request);
var newToken = await _mediator.Send(command);
return new AcceptClubMembershipContractResponse
{
Success = true,
Message = "قرارداد با موفقیت امضا شد",
NewToken = newToken ?? string.Empty
};
}
catch (Exception ex)
{
return new AcceptClubMembershipContractResponse
{
Success = false,
Message = ex.Message,
NewToken = string.Empty
};
}
}
```
---
### 9️⃣ ClubMembershipProfile.cs
```csharp
using FrontOffice.BFF.Application.ClubMemberships.Commands.AcceptClubMembershipContract;
using FrontOffice.BFF.Application.ClubMemberships.Commands.RequestClubContractOtp;
using FrontOffice.BFF.ClubMembership.Protobuf;
using Mapster;
namespace FrontOffice.BFF.Application.Profiles;
public class ClubMembershipProfile : IRegister
{
public void Register(TypeAdapterConfig config)
{
// ... other mappings ...
config.NewConfig<RequestClubContractOtpRequest, RequestClubContractOtpCommand>()
.Map(dest => dest.SignGuid, src => src.SignGuid);
config.NewConfig<AcceptClubMembershipContractRequest, AcceptClubMembershipContractCommand>()
.Map(dest => dest.OtpCode, src => src.OtpCode)
.Map(dest => dest.SignGuid, src => src.SignGuid)
.Map(dest => dest.ContractHtml, src => src.ContractHtml);
}
}
```
---
## 🎨 Frontend Layer
### 📂 File Structure:
```
FrontOffice/
src/FrontOffice.Main/
Pages/Profile/
Index.razor.cs ✅ Modified
Components/Dialogs/
ClubMembershipContractDialog.razor ✅ Created
```
---
### 1️⃣ ClubMembershipContractDialog.razor
```razor
@using FrontOffice.BFF.ClubMembership.Protobuf
@inject ClubMembership.ClubMembershipClient ClubMembershipClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject ILocalStorageService LocalStorage
@implements IDisposable
<MudDialog>
<DialogContent>
<MudContainer MaxWidth="MaxWidth.Medium" Class="pa-4">
@if (_currentStep == ContractStep.ReadContract)
{
<MudText Typo="Typo.h6" Class="mb-4">📜 قرارداد باشگاه مشتریان کارابازار</MudText>
<MudPaper Class="pa-4 mb-4" Style="max-height: 400px; overflow-y: auto;">
@((MarkupString)GetClubContractHtml())
</MudPaper>
<MudAlert Severity="Severity.Info" Class="mb-4">
⚠️ توجه: برای استفاده از امکانات باشگاه مشتریان و فعالسازی لینک دعوت، باید این قرارداد را امضا کنید.
</MudAlert>
<MudButton
Variant="Variant.Filled"
Color="Color.Primary"
FullWidth="true"
OnClick="RequestOtp"
Disabled="_isLoading">
@if (_isLoading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
<span class="ms-2">در حال ارسال...</span>
}
else
{
<span>✅ مطالعه کردم، درخواست کد تایید</span>
}
</MudButton>
}
else if (_currentStep == ContractStep.EnterOtp)
{
<MudText Typo="Typo.h6" Class="mb-4">🔐 تایید امضای قرارداد</MudText>
<MudAlert Severity="Severity.Success" Class="mb-4">
✅ کد تایید به شماره موبایل شما ارسال شد.
</MudAlert>
<MudTextField
@bind-Value="_otpCode"
Label="کد تایید (6 رقمی)"
Variant="Variant.Outlined"
MaxLength="6"
InputMode="InputMode.numeric"
HelperText="@($"زمان باقی‌مانده: {_remainingSeconds} ثانیه")"
Class="mb-4" />
<MudButton
Variant="Variant.Filled"
Color="Color.Primary"
FullWidth="true"
OnClick="AcceptContract"
Disabled="_isLoading || string.IsNullOrWhiteSpace(_otpCode) || _otpCode.Length != 6">
@if (_isLoading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
<span class="ms-2">در حال تایید...</span>
}
else
{
<span>✍️ امضای قرارداد</span>
}
</MudButton>
<MudButton
Variant="Variant.Text"
Color="Color.Secondary"
FullWidth="true"
Class="mt-2"
OnClick="RequestOtp"
Disabled="_isLoading || _remainingSeconds > 0">
🔄 ارسال مجدد کد
</MudButton>
}
else if (_currentStep == ContractStep.Success)
{
<MudText Typo="Typo.h6" Class="mb-4 text-center">🎉 تبریک!</MudText>
<MudAlert Severity="Severity.Success" Class="mb-4">
✅ قرارداد با موفقیت امضا شد و باشگاه مشتریان شما فعال شد.
اکنون می‌توانید از لینک دعوت استفاده کنید.
</MudAlert>
<MudButton
Variant="Variant.Filled"
Color="Color.Primary"
FullWidth="true"
OnClick="CloseAndRefresh">
✅ متوجه شدم
</MudButton>
}
</MudContainer>
</DialogContent>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
private enum ContractStep
{
ReadContract,
EnterOtp,
Success
}
private ContractStep _currentStep = ContractStep.ReadContract;
private bool _isLoading;
private string _signGuid = Guid.NewGuid().ToString();
private string _otpCode = string.Empty;
private int _remainingSeconds = 120;
private System.Threading.Timer? _timer;
private async Task RequestOtp()
{
_isLoading = true;
try
{
var response = await ClubMembershipClient.RequestClubContractOtpAsync(
new RequestClubContractOtpRequest { SignGuid = _signGuid });
if (response.Success)
{
_currentStep = ContractStep.EnterOtp;
_remainingSeconds = 120;
StartTimer();
Snackbar.Add("کد تایید ارسال شد", Severity.Success);
}
else
{
Snackbar.Add(response.Message ?? "خطا در ارسال کد تایید", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
}
finally
{
_isLoading = false;
}
}
private async Task AcceptContract()
{
_isLoading = true;
try
{
var response = await ClubMembershipClient.AcceptClubMembershipContractAsync(
new AcceptClubMembershipContractRequest
{
OtpCode = _otpCode,
SignGuid = _signGuid,
ContractHtml = GetClubContractHtml()
});
if (response.Success)
{
// ذخیره token جدید
if (!string.IsNullOrEmpty(response.NewToken))
{
await LocalStorage.SetItemAsStringAsync("token", response.NewToken);
}
_currentStep = ContractStep.Success;
StopTimer();
Snackbar.Add("قرارداد با موفقیت امضا شد", Severity.Success);
}
else
{
Snackbar.Add(response.Message ?? "خطا در امضای قرارداد", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
}
finally
{
_isLoading = false;
}
}
private void CloseAndRefresh()
{
// بستن modal و refresh صفحه برای نمایش لینک دعوت
Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
}
private void StartTimer()
{
_timer = new System.Threading.Timer(_ =>
{
if (_remainingSeconds > 0)
{
_remainingSeconds--;
InvokeAsync(StateHasChanged);
}
else
{
StopTimer();
}
}, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
private void StopTimer()
{
_timer?.Dispose();
_timer = null;
}
public void Dispose()
{
StopTimer();
}
private string GetClubContractHtml()
{
return @"
<div style='font-family: IRANSans, Tahoma; line-height: 1.8;'>
<h3 style='text-align: center;'>قرارداد عضویت در باشگاه مشتریان کارابازار</h3>
<p>این قرارداد بین کاربر محترم (عضو باشگاه) و شرکت کارابازار منعقد می‌گردد.</p>
<h4>ماده 1: تعهدات شرکت</h4>
<ul>
<li>ارائه خدمات باشگاه مشتریان طبق شرایط اعلام شده</li>
<li>امکان دعوت سایر کاربران از طریق لینک دعوت اختصاصی</li>
<li>دریافت کمیسیون از خریدهای زیرمجموعه‌ها</li>
</ul>
<h4>ماده 2: تعهدات کاربر</h4>
<ul>
<li>رعایت قوانین و مقررات باشگاه مشتریان</li>
<li>عدم سوء استفاده از لینک دعوت</li>
<li>رعایت اصول اخلاقی در معرفی افراد</li>
</ul>
<h4>ماده 3: جزئیات مالی</h4>
<ul>
<li>مبلغ پرداختی: 56,000,000 تومان</li>
<li>ارزش هدیه: 25,200,000 تومان (45% مبلغ پرداختی)</li>
<li>کل شارژ کیف پول: 56,000,000 تومان</li>
</ul>
<p style='margin-top: 20px;'>
<strong>با امضای این قرارداد، شما تمامی شرایط و ضوابط فوق را می‌پذیرید.</strong>
</p>
</div>
";
}
}
```
**نکات کلیدی:**
- ✅ استفاده از `IMudDialogInstance` (نه `MudDialogInstance`)
- ✅ سه مرحله: ReadContract → EnterOtp → Success
- ✅ Timer countdown برای OTP (120 ثانیه)
- ✅ ذخیره token جدید در localStorage
- ✅ Refresh صفحه بعد از موفقیت
- ✅ HTML contract content در `GetClubContractHtml()`
---
### 2️⃣ Index.razor.cs (Profile Page)
```csharp
private bool _hasPurchasedPackage;
private bool _isClubMemberActive;
private bool CanShowReferralLink => _hasPurchasedPackage && _isClubMemberActive;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await LoadUserData();
await CheckAndShowClubContractModal();
StateHasChanged();
}
}
private async Task CheckAndShowClubContractModal()
{
// اگر کاربر پکیج خریده ولی قرارداد امضا نکرده
if (_hasPurchasedPackage && !_isClubMemberActive)
{
var options = new DialogOptions
{
BackdropClick = false, // غیرقابل بسته شدن با کلیک بیرون
CloseOnEscapeKey = false, // غیرقابل بسته شدن با Escape
CloseButton = false, // بدون دکمه Close
MaxWidth = MaxWidth.Medium,
FullWidth = true
};
await DialogService.ShowAsync<ClubMembershipContractDialog>("", options);
}
}
```
**نکات کلیدی:**
-`BackdropClick = false` (نه `DisableBackdropClick`)
-`CloseOnEscapeKey = false`
-`CloseButton = false`
- ✅ فراخوانی در `OnAfterRenderAsync`
---
## 📱 OTP SMS Format
### Message Template:
```
سلام {نام کاربر} عزیز
کد یک بار مصرف برای تایید قرارداد باشگاه مشتریان: {کد 6 رقمی}
شناسه امضاء: {GUID}
کارابازار
```
### Real Example:
```
سلام علی احمدی عزیز
کد یک بار مصرف برای تایید قرارداد باشگاه مشتریان: 123456
شناسه امضاء: a1b2c3d4-e5f6-7890-abcd-ef1234567890
کارابازار
```
### Code Implementation:
```csharp
await _kavenegarService.Send(
mobile: mobileNumber,
new StringBuilder("سلام ")
.Append(fullName)
.AppendLine(" عزیز")
.Append("کد یک بار مصرف برای تایید قرارداد باشگاه مشتریان: ")
.AppendLine(otpResponse.Code)
.AppendLine("شناسه امضاء: ")
.AppendLine(request.SignGuid)
.AppendLine("کارابازار")
.ToString());
```
---
## 🔄 Token Refresh Pattern
### چرا Token Refresh؟
بعد از امضای قرارداد، وضعیت کاربر تغییر می‌کند:
- `ClubMembership.IsActive` از `false` به `true` تغییر می‌کند
- JWT Token فعلی claim‌های قدیمی دارد
- برای نمایش لینک دعوت، نیاز به token جدید با claim‌های به‌روز شده داریم
### Flow:
```
1. AcceptContract موفق شد
2. BFF.AcceptClubMembershipContractCommandHandler
├─ فراخوانی CMS.AcceptClubMembershipContract
└─ فراخوانی CMS.GetJwtToken(userId) → token جدید
3. Frontend دریافت token جدید
└─ ذخیره در localStorage
4. Refresh صفحه
└─ لینک دعوت نمایش داده می‌شود
```
### Code:
```csharp
// BFF Handler
var tokenResponse = await _context.User.GetJwtTokenAsync(
new GetJwtTokenRequest { Id = userId },
cancellationToken: cancellationToken);
return tokenResponse?.Token;
```
```csharp
// Frontend
if (!string.IsNullOrEmpty(response.NewToken))
{
await LocalStorage.SetItemAsStringAsync("token", response.NewToken);
}
Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
```
---
## ✅ Testing Checklist
### 1️⃣ CMS Layer Tests:
- [ ] `AcceptClubMembershipContractCommandValidator` validation rules
- [ ] `AcceptClubMembershipContractCommandHandler`:
- [ ] کاربر یافت نمی‌شود → Exception
- [ ] PackagePurchaseMethod = None → Exception
- [ ] ClubMembership.IsActive = true → Exception (جلوگیری از امضای مجدد)
- [ ] OTP نامعتبر → Exception
- [ ] OTP منقضی شده → Exception
- [ ] امضای موفق → ClubMembership.IsActive = true
- [ ] مقادیر صحیح: InitialContribution, GiftValue, ActivatedAt
### 2️⃣ BFF Layer Tests:
- [ ] `RequestClubContractOtpCommandHandler`:
- [ ] MobileNumber از CurrentUserService دریافت می‌شود
- [ ] OTP از CMS دریافت می‌شود
- [ ] پیامک با IKavenegarService ارسال می‌شود
- [ ] SignGuid در پیامک موجود است
- [ ] `AcceptClubMembershipContractCommandHandler`:
- [ ] فراخوانی CMS موفق
- [ ] Token جدید دریافت و return می‌شود
### 3️⃣ Frontend Tests:
- [ ] Modal نمایش داده می‌شود وقتی `HasPurchasedPackage && !IsClubMemberActive`
- [ ] Modal **غیرقابل بسته شدن** است (Escape, Backdrop Click, Close Button)
- [ ] درخواست OTP موفق → مرحله EnterOtp
- [ ] Timer countdown کار می‌کند (120 ثانیه)
- [ ] امضای موفق → مرحله Success
- [ ] Token refresh و reload صفحه
- [ ] لینک دعوت نمایش داده می‌شود
### 4️⃣ Integration Tests:
- [ ] End-to-End Flow: Payment → Modal → OTP → Sign → Refresh → Referral Link
- [ ] پیامک واقعی ارسال می‌شود
- [ ] Contract و UserContract در دیتابیس ثبت می‌شود
- [ ] ClubMembership فعال می‌شود
---
## 🔗 Related Documents
- [Base Package Payment System](./base-package-payment-system.md)
- [Network Commission System](./network-commission-system.md)
- [Binary Tree Guide](./binary-tree-guide.md)
---
## 📝 Notes
### تغییرات مهم:
1. **ContractType.ClubMembership** - نام تغییر کرد از `CMS` به `ClubMembership`
2. **IKavenegarService** - الزامی برای ارسال پیامک در BFF
3. **IMudDialogInstance** - type صحیح برای MudDialog
4. **BackdropClick** - جایگزین `DisableBackdropClick`
### نکات امنیتی:
- ✅ OTP Verification قبل از امضا
- ✅ جلوگیری از امضای مجدد (ClubMembership.IsActive check)
- ✅ بررسی PackagePurchaseMethod (کاربر باید پکیج خریده باشد)
- ✅ Token Refresh برای claims جدید
### Known Issues:
- هیچ موردی گزارش نشده ✅
---
**آخرین به‌روزرسانی:** 2024-12-16
**مستندساز:** GitHub Copilot
**وضعیت Build:** ✅ All Green (CMS, BFF, Frontend)