1423 lines
45 KiB
Markdown
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)
|