76 KiB
داکیومنت فنی پیادهسازی سامانه گردونه شانس و قرعهکشی
نسخه سند: 2.0 (Implementation Blueprint) بر اساس: Lucky Wheel Lottery Spec v1.0 نوع سند: Technical Architecture & Implementation Guide Stack: NestJS + Prisma + PostgreSQL (Backend) | Vue 3 + Vite + Pinia + Ant Design Vue (Admin) | Vue 3 + Vite + Pinia (Client) | BullMQ + Redis (Queue) | raygansms.com (SMS Provider)
فهرست مطالب
- مقدمه
- Tech Stack نهایی
- معماری کلی و ساختار Monorepo
- مدل داده — Prisma Schema کامل
- ساختار پروژه Backend (NestJS)
- ماژول Authentication (OTP)
- ماژول Campaign
- ماژول Prize
- ماژول Chance و Chance Ledger
- Spin Engine — الگوریتم اجرایی و کنترل Concurrency
- Idempotency
- ماژول SMS (raygansms.com) + صف BullMQ
- ماژول Winner / User Prizes / کد جایزه
- ماژول File Storage (Local، قابل تعویض به S3)
- مستندات کامل API — کاربر
- مستندات کامل API — ادمین
- ساختار پروژه Admin Panel (Vue 3 + Ant Design Vue + Pinia)
- ساختار پروژه User Client (Vue 3 + Wheel Component)
- پنج طرح گردونه (Wheel Designs) قابل انتخاب توسط ادمین
- Security و Anti-Fraud
- Rate Limiting
- Environment Variables
- Docker / Deployment
- استراتژی تست و Mapping با Acceptance Criteria
- جمعبندی و مسیر توسعه آینده
0. مقدمه
این سند، پیادهسازی فنی مشخصات کسبوکاری موجود در Lucky_Wheel_Lottery_Spec_v1.0 را با Stack مشخصشده زیر تعریف میکند:
| لایه | تکنولوژی |
|---|---|
| Backend Framework | NestJS (Node.js, TypeScript) |
| ORM | Prisma |
| Database | PostgreSQL |
| Admin Panel Frontend | Vue 3 + Vite + Pinia + Ant Design Vue |
| User Client Frontend | Vue 3 + Vite + Pinia |
| SMS Provider | raygansms.com |
| Queue / Job System | BullMQ + Redis |
| File Storage | Local Disk در فاز فعلی (طراحیشده برای مهاجرت بدون تغییر Business Logic به S3-Compatible مثل لیارا/آروان در آینده) |
| Auth کاربر | OTP + JWT |
| Auth ادمین | Email/Password + JWT (Access + Refresh) |
اصول معماری بدون تغییر نسبت به سند اصلی حفظ میشوند:
- نتیجه Spin همیشه در Backend تعیین میشود.
- عملیات Spin بهصورت Atomic و داخل Database Transaction اجرا میشود.
- تمام Endpointهای حساس نیازمند Authentication هستند.
- تمام تغییرات Chance و اقدامات ادمین Audit میشوند.
1. Tech Stack نهایی
Backend
- Runtime: Node.js 20 LTS
- Framework: NestJS 10.x (Modular Architecture, Dependency Injection)
- ORM: Prisma 5.x
- Database: PostgreSQL 15+
- Queue: BullMQ روی Redis 7+ (برای SMS، و قابل توسعه برای Jobهای آینده مثل گزارشهای سنگین)
- Validation:
class-validator+class-transformer(DTO-based) - Auth:
@nestjs/jwt+@nestjs/passport(Strategy جدا برای User و Admin) - Rate Limiting:
@nestjs/throttler - API Docs:
@nestjs/swagger(Swagger UI برای مستندسازی زنده API) - Logger:
pino(nestjs-pino) برای Structured Logging - Scheduler:
@nestjs/schedule(برای Cron Jobهایی مانند انقضای جوایز)
Admin Panel
- Vue 3 (Composition API +
<script setup>) - Vite
- Pinia (State Management)
- Ant Design Vue 4.x (UI Kit)
- Vue Router 4
- Axios (با Interceptor برای Refresh Token)
- ECharts یا
@antv/g2برای نمودارهای Dashboard (سازگاری خوب با Ant Design) dayjsبرای تاریخ (سازگار با Ant Design Vue) — با پشتیبانی از نمایش تاریخ شمسی از طریقdayjs-jalaliدر لایه نمایش
User Client
- Vue 3 (Composition API)
- Vite
- Pinia
- Tailwind CSS برای استایلدهی سریع Mobile-First
- GSAP یا CSS Animation خالص برای انیمیشن گردونه (بسته به طرح انتخابی — بخش 18)
- Axios
Infrastructure
- Docker + Docker Compose برای Dev/Staging
- PostgreSQL Container
- Redis Container
- Nginx بهعنوان Reverse Proxy (سرو فایلهای استاتیک گردونه + پروکسی API)
2. معماری کلی و ساختار Monorepo
lucky-wheel/
├── apps/
│ ├── api/ # NestJS Backend
│ ├── admin/ # Vue 3 + AntD Vue Admin Panel
│ └── client/ # Vue 3 User-Facing Wheel App
├── packages/
│ └── shared-types/ # Type/Enum/DTO مشترک بین Backend و دو Frontend
├── docker-compose.yml
├── docker-compose.prod.yml
└── package.json # pnpm workspace root
استفاده از pnpm workspaces پیشنهاد میشود تا shared-types (Enumها، اینترفیس Response API، ثابتهای خطا) بین سه اپلیکیشن به اشتراک گذاشته شود و از Type Drift بین Backend و Frontend جلوگیری شود.
دیاگرام معماری
┌────────────────────┐ ┌──────────────────────┐
│ User Client │ │ Admin Panel │
│ Vue3 + Pinia │ │ Vue3 + AntD + Pinia │
│ (Mobile First) │ │ (Desktop First) │
└─────────┬───────────┘ └───────────┬───────────┘
│ HTTPS / JWT │ HTTPS / JWT (Admin)
▼ ▼
┌─────────────────────────────────────────────────────┐
│ NestJS API Gateway │
│ ┌───────────┐ ┌────────────┐ ┌───────────────────┐ │
│ │ AuthModule │ │CampaignMod │ │ SpinEngineModule │ │
│ ├───────────┤ ├────────────┤ ├───────────────────┤ │
│ │ ChanceMod │ │ PrizeMod │ │ WinnerModule │ │
│ ├───────────┤ ├────────────┤ ├───────────────────┤ │
│ │ SmsModule │ │ ReportMod │ │ AdminAuditModule │ │
│ └───────────┘ └────────────┘ └───────────────────┘ │
└───────────────────────┬───────────────────────────────┘
│ Prisma Client
▼
┌──────────────────┐
│ PostgreSQL │
└──────────────────┘
┌──────────────────┐ ┌────────────────┐
│ BullMQ Queue │───────▶│ raygansms.com │
│ (Redis-backed) │ │ SMS Provider │
└──────────────────┘ └────────────────┘
اصل جداسازی مسئولیت (اجباری طبق سند اصلی)
SpinEngineModuleهیچ وابستگی به Framework ارائه (Frontend) ندارد و صرفاً یک Service با ورودی/خروجی مشخص است؛ در آینده قابل جایگزینی با الگوریتم دیگر (مثلاً Weighted Random پیشرفتهتر یا Segment Pity System) بدون تغییر در Controller یا Frontend.SmsModuleپشت یک Interface به نامSmsProviderInterfaceقرار میگیرد؛ پیادهسازی فعلیRayganSmsProviderاست و تعویض Provider فقط نیازمند یک Adapter جدید است.FileStorageModuleپشتStorageProviderInterfaceقرار میگیرد؛ پیادهسازی فعلیLocalStorageProviderو آماده برایS3StorageProvider.
3. مدل داده — Prisma Schema کامل
فایل apps/api/prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ==================== ENUMS ====================
enum AdminStatus {
ACTIVE
DISABLED
}
enum AdminRole {
SUPER_ADMIN
OPERATOR
SUPPORT
}
enum UserStatus {
ACTIVE
BLOCKED
}
enum CampaignStatus {
DRAFT
ACTIVE
PAUSED
FINISHED
}
enum PrizeType {
PHYSICAL
DISCOUNT_CODE
CREDIT
CASH
NO_PRIZE
OTHER
}
enum StockType {
LIMITED
UNLIMITED
}
enum PrizeStatus {
ACTIVE
INACTIVE
}
enum SpinStatus {
SUCCESS
FAILED
DUPLICATE
}
enum ChanceTxnType {
SIGNUP_BONUS
ADMIN_ADJUSTMENT
SPIN_CONSUME
CAMPAIGN_BONUS
REFUND
}
enum UserPrizeStatus {
NEW
CONTACTED
DELIVERED
ACTIVE_CODE
USED
EXPIRED
}
enum SmsType {
OTP
WINNING
NO_PRIZE
PRIZE_REMINDER
}
enum SmsStatus {
PENDING
SENT
FAILED
}
enum WheelDesign {
CLASSIC_CASINO
NEON_CYBER
MINIMAL_FLAT
LUXURY_GOLD
PLAYFUL_CARTOON
}
// ==================== MODELS ====================
model Admin {
id String @id @default(uuid())
name String
mobile String @unique
email String @unique
passwordHash String @map("password_hash")
role AdminRole @default(OPERATOR)
status AdminStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
auditLogs AdminLog[]
chanceTxns ChanceTransaction[] @relation("AdminCreatedTxn")
@@map("admins")
}
model User {
id String @id @default(uuid())
mobile String @unique
name String?
status UserStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
userCampaigns UserCampaign[]
chanceTransactions ChanceTransaction[]
spins Spin[]
userPrizes UserPrize[]
otpCodes OtpCode[]
smsLogs SmsLog[]
@@index([mobile])
@@map("users")
}
model OtpCode {
id String @id @default(uuid())
mobile String
code String
userId String? @map("user_id")
user User? @relation(fields: [userId], references: [id])
attempts Int @default(0)
consumed Boolean @default(false)
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([mobile, consumed])
@@map("otp_codes")
}
model Campaign {
id String @id @default(uuid())
title String
slug String @unique
description String?
image String?
banner String?
logo String?
startAt DateTime @map("start_at")
endAt DateTime @map("end_at")
status CampaignStatus @default(DRAFT)
defaultChances Int @default(0) @map("default_chances")
rules String?
ctaText String? @map("cta_text")
wheelDesign WheelDesign @default(CLASSIC_CASINO) @map("wheel_design")
settings Json? // primaryColor, secondaryColor, headerText, buttonText, headerImage, celebrationEffect...
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
prizes Prize[]
userCampaigns UserCampaign[]
chanceTxns ChanceTransaction[]
spins Spin[]
userPrizes UserPrize[]
@@index([status, startAt, endAt])
@@map("campaigns")
}
model Prize {
id String @id @default(uuid())
campaignId String @map("campaign_id")
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
title String
description String?
image String?
type PrizeType
stockType StockType @map("stock_type")
initialStock Int? @map("initial_stock")
remainingStock Int? @map("remaining_stock")
weight Int
status PrizeStatus @default(ACTIVE)
sortOrder Int @default(0) @map("sort_order")
segmentColor String @map("segment_color")
expiresAt DateTime? @map("expires_at")
smsTemplate String? @map("sms_template")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
spins Spin[]
userPrizes UserPrize[]
@@index([campaignId, status])
@@map("prizes")
}
model UserCampaign {
id String @id @default(uuid())
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
campaignId String @map("campaign_id")
campaign Campaign @relation(fields: [campaignId], references: [id])
totalChances Int @default(0) @map("total_chances")
usedChances Int @default(0) @map("used_chances")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([userId, campaignId])
@@map("user_campaigns")
}
model ChanceTransaction {
id String @id @default(uuid())
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
campaignId String @map("campaign_id")
campaign Campaign @relation(fields: [campaignId], references: [id])
amount Int // مثبت یا منفی
type ChanceTxnType
referenceType String? @map("reference_type") // e.g. "spin", "manual"
referenceId String? @map("reference_id")
description String?
createdById String? @map("created_by") // admin id در صورت تغییر دستی
createdBy Admin? @relation("AdminCreatedTxn", fields: [createdById], references: [id])
createdAt DateTime @default(now()) @map("created_at")
@@index([userId, campaignId])
@@map("chance_transactions")
}
model Spin {
id String @id @default(uuid())
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
campaignId String @map("campaign_id")
campaign Campaign @relation(fields: [campaignId], references: [id])
prizeId String? @map("prize_id")
prize Prize? @relation(fields: [prizeId], references: [id])
status SpinStatus @default(SUCCESS)
idempotencyKey String @unique @map("idempotency_key")
ip String?
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at")
userPrize UserPrize?
@@index([userId, campaignId])
@@index([campaignId, createdAt])
@@map("spins")
}
model UserPrize {
id String @id @default(uuid())
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
campaignId String @map("campaign_id")
campaign Campaign @relation(fields: [campaignId], references: [id])
prizeId String @map("prize_id")
prize Prize @relation(fields: [prizeId], references: [id])
spinId String @unique @map("spin_id")
spin Spin @relation(fields: [spinId], references: [id])
code String? @unique
status UserPrizeStatus @default(NEW)
wonAt DateTime @default(now()) @map("won_at")
redeemedAt DateTime? @map("redeemed_at")
@@index([userId])
@@index([status])
@@map("user_prizes")
}
model SmsLog {
id String @id @default(uuid())
userId String? @map("user_id")
user User? @relation(fields: [userId], references: [id])
mobile String
type SmsType
message String
provider String @default("raygansms")
status SmsStatus @default(PENDING)
providerResponse Json? @map("provider_response")
createdAt DateTime @default(now()) @map("created_at")
@@index([mobile, type])
@@map("sms_logs")
}
model AdminLog {
id String @id @default(uuid())
adminId String @map("admin_id")
admin Admin @relation(fields: [adminId], references: [id])
action String // e.g. "prize.update", "chance.add"
entityType String @map("entity_type")
entityId String @map("entity_id")
oldValue Json? @map("old_value")
newValue Json? @map("new_value")
ip String?
createdAt DateTime @default(now()) @map("created_at")
@@index([entityType, entityId])
@@map("admin_logs")
}
نکات کلیدی مدل داده
idempotency_keyرویSpinبا@unique— تضمین سطح دیتابیس برای AC-09 (جلوگیری از مصرف دوباره Chance).user_prizes.spin_idبا@unique— هر Spin حداکثر یک UserPrize تولید میکند (رابطه یکبهیک).user_campaignsبا@@unique([userId, campaignId])— هر کاربر در هر کمپین دقیقاً یک رکورد Summary شانس دارد؛ بهروزرسانی این رکورد همیشه همزمان با ثبت رکورد درchance_transactionsانجام میشود (Ledger + Snapshot).Campaign.wheelDesignفیلد جدید نسبت به سند اصلی است تا ادمین هنگام ساخت/ویرایش کمپین، یکی از ۵ طرح گردونه (بخش ۱۸) را انتخاب کند.Campaign.settings(Json) شامل رنگبندی سفارشی، متنها و افکت جشن، مستقل ازwheelDesign(طرح پایه) است تا هر طرح هم قابل شخصیسازی برند باشد.
4. ساختار پروژه Backend (NestJS)
apps/api/
├── prisma/
│ ├── schema.prisma
│ └── migrations/
├── src/
│ ├── main.ts
│ ├── app.module.ts
│ ├── common/
│ │ ├── guards/ # JwtUserGuard, JwtAdminGuard, RolesGuard
│ │ ├── interceptors/ # AuditInterceptor, ResponseInterceptor
│ │ ├── filters/ # AllExceptionsFilter
│ │ ├── decorators/ # @CurrentUser(), @CurrentAdmin()
│ │ └── pipes/
│ ├── prisma/
│ │ └── prisma.service.ts
│ ├── modules/
│ │ ├── auth/ # OTP + JWT کاربر
│ │ ├── admin-auth/ # Login ادمین
│ │ ├── campaign/
│ │ ├── prize/
│ │ ├── chance/
│ │ ├── spin-engine/ # هسته منطق قرعهکشی
│ │ ├── winner/
│ │ ├── user/
│ │ ├── sms/
│ │ │ ├── providers/
│ │ │ │ └── raygan-sms.provider.ts
│ │ │ ├── sms.interface.ts
│ │ │ ├── sms.processor.ts # BullMQ Worker
│ │ │ └── sms.service.ts
│ │ ├── storage/
│ │ │ ├── providers/local-storage.provider.ts
│ │ │ └── storage.interface.ts
│ │ ├── report/
│ │ └── admin-audit/
│ └── config/
│ └── env.validation.ts
└── test/
هر ماژول از الگوی استاندارد NestJS پیروی میکند: *.module.ts / *.controller.ts / *.service.ts / dto/ / entities (Prisma types).
5. ماژول Authentication (OTP)
Endpoints
| Method | Route | توضیح |
|---|---|---|
| POST | /api/auth/send-otp |
دریافت mobile، تولید کد ۵ رقمی، ثبت در otp_codes، ارسال با صف SMS |
| POST | /api/auth/verify-otp |
دریافت mobile + code، صدور JWT در صورت صحت |
قوانین پیادهسازی
- کد OTP با
crypto.randomInt(10000, 99999)تولید و با هش (bcrypt یا HMAC-SHA256 با Secret) در دیتابیس ذخیره میشود — کد خام هرگز Persist نمیشود. - TTL کد: ۲ دقیقه (
expires_at). - محدودیت ارسال مجدد: هر شماره حداکثر یک OTP فعال در بازه ۶۰ ثانیه (کنترل با بررسی آخرین
otp_codesرکورد + Throttler سراسری روی IP). - محدودیت تلاش اشتباه: حداکثر ۵ تلاش روی هر کد؛ عبور از آن → کد باطل و باید OTP جدید بگیرد.
- در
verify-otpموفق: اگر کاربر با آن Mobile وجود نداشت،Userجدید ساخته میشود (Upsert)، سپس JWT (Access Token با انقضای مثلاً ۷ روز، مناسب اپ گردونهای که Session کوتاه لازم ندارد) صادر میشود. last_login_atدر هر verify موفق بهروزرسانی میشود.
JWT Payload کاربر
{
"sub": "user-uuid",
"mobile": "0912xxxxxxx",
"type": "user"
}
Guard مربوطه (JwtUserGuard) صرفاً Tokenهایی با type: "user" را میپذیرد تا امکان استفاده از Token ادمین برای APIهای کاربر (و برعکس) وجود نداشته باشد.
6. ماژول Campaign
CRUD ادمین
POST /api/admin/campaignsGET /api/admin/campaigns(با Pagination/Filter بر اساس status)GET /api/admin/campaigns/:idPATCH /api/admin/campaigns/:idDELETE /api/admin/campaigns/:id(Soft delete توصیه میشود — تغییر status به یک مقدار Archived در آینده، یا صرفاً منع حذف کمپینهایی که Spin دارند)POST /api/admin/campaigns/:id/duplicate(کپی کمپین + جوایز برای کمپینهای تکرارشونده — فراتر از MVP ولی کمهزینه در پیادهسازی)
اعتبارسنجی کسبوکاری (Service Layer، نه فقط DTO)
slugیکتا و فقط شاملa-z 0-9 -.end_atباید بعد ازstart_atباشد.- تغییر
statusبهACTIVEفقط زمانی مجاز است که کمپین حداقل یک Prize فعال با Weight بزرگتر از صفر داشته باشد. - انتخاب
wheelDesignاز Enum ثابت پنجگانه (بخش ۱۸)؛ در UI ادمین بهصورت Visual Picker (Thumbnail هر طرح) نمایش داده میشود، نه Dropdown متنی.
تشخیص «کمپین فعال» (Service مشترک CampaignAvailabilityService)
isCampaignAvailable(campaign: Campaign): boolean {
const now = new Date();
return campaign.status === 'ACTIVE'
&& now >= campaign.startAt
&& now <= campaign.endAt;
}
این Service در سهجا استفاده میشود: نمایش صفحه گردونه به کاربر، اعتبارسنجی قبل از Spin، و Cron Job ساعتی که کمپینهای منقضیشده را بهصورت خودکار به FINISHED تغییر میدهد (تجربه بهتر برای گزارشگیری، بدون اتکا به محاسبه Real-time در هر Query گزارش).
7. ماژول Prize
CRUD ادمین
POST /api/admin/campaigns/:campaignId/prizesPATCH /api/admin/prizes/:idDELETE /api/admin/prizes/:id(فقط اگر Spin ثبتشدهای به آن ارجاع نداده باشد؛ در غیر این صورت فقطstatus = INACTIVE)PATCH /api/admin/prizes/:id/stock(افزایش موجودی دستی، با ثبت در Admin Log)
قوانین
- اگر
stockType = LIMITED، فیلدinitialStockالزامی وremainingStockهنگام ایجاد برابرinitialStockمقداردهی میشود. - اگر
stockType = UNLIMITED،initialStockوremainingStockبایدnullباشند (Prize Pool آنها هرگز از انتخاب خارج نمیشود). weightباید عدد صحیح مثبت باشد (۰ مجاز نیست چون عملاً یعنی این Prize هرگز انتخاب نمیشود — بهتر است ادمین آن راINACTIVEکند).segmentColorباید Hex معتبر باشد (اعتبارسنجی در DTO با Regex).- تغییر Weight یا Stock توسط ادمین همیشه یک رکورد
AdminLogباoldValue/newValueتولید میکند (مطابق بخش ۲۵ سند اصلی).
8. ماژول Chance و Chance Ledger
Snapshot + Ledger (هر دو با هم، هرگز جدا)
هر تغییر شانس (اضافه/کم کردن/مصرف در Spin) دقیقاً در یک Transaction دیتابیسی این دو کار را همزمان انجام میدهد:
- درج یک ردیف در
chance_transactions(Ledger — تغییرناپذیر، هرگز Update/Delete نمیشود). - بهروزرسانی
user_campaigns.total_chancesیاused_chances(Snapshot — برای Query سریع بدون نیاز بهSUM()روی کل Ledger).
// ChanceService.adjustChance (استفاده در Signup Bonus / Admin Adjustment / Refund)
async adjustChance(tx: PrismaTx, params: {
userId: string; campaignId: string; amount: number;
type: ChanceTxnType; createdById?: string; description?: string;
}) {
await tx.chanceTransaction.create({ data: { ...params } });
await tx.userCampaign.upsert({
where: { userId_campaignId: { userId: params.userId, campaignId: params.campaignId } },
create: {
userId: params.userId,
campaignId: params.campaignId,
totalChances: Math.max(params.amount, 0),
usedChances: 0,
},
update: {
totalChances: { increment: params.amount > 0 ? params.amount : 0 },
// برای amount منفی که از نوع "مصرف" نیست (مثلا اصلاح دستی کاهشی)، totalChances مستقیم کم میشود:
...(params.type !== 'SPIN_CONSUME' && params.amount < 0
? { totalChances: { decrement: Math.abs(params.amount) } }
: {}),
},
});
}
نکته: مصرف شانس ناشی از Spin (
SPIN_CONSUME) مقدارusedChancesرا افزایش میدهد نهtotalChancesرا کاهش — به این ترتیب Ledger کامل و قابل Reconciliation باقی میماند وremaining_chances = totalChances - usedChancesهمیشه محاسبهپذیر است، حتی برای Audit تاریخی.
API مدیریت شانس (ادمین)
POST /api/admin/users/:userId/campaigns/:campaignId/chances— بدنه:{ amount: number, description: string }؛ میتواند مثبت یا منفی باشد.- هر فراخوانی این API علاوه بر
ChanceTransaction، یکAdminLogنیز ثبت میکند (مطابق نمونه بخش ۲۵ سند اصلی: «Admin X برای کاربر ۰۹۱۲... ۵ شانس اضافه کرد»).
9. Spin Engine — الگوریتم اجرایی و کنترل Concurrency
این حساسترین بخش کل سیستم است. پیادهسازی باید دقیقاً منطبق با اصل «Backend-Only Decision» و AC-01 تا AC-09 باشد.
۹.۱ ورودی و خروجی
// POST /api/campaigns/:campaignId/spin
// Body:
interface SpinRequestDto {
idempotencyKey: string; // UUID تولیدشده در Frontend، یکبار مصرف
}
// Response موفق:
interface SpinResponseDto {
success: true;
spinId: string;
prize: { id: string; title: string; segment: number; code: string | null; type: PrizeType } | null; // null یعنی No-Prize بدون رکورد UserPrize
remainingChances: number;
}
۹.۲ ترتیب اجرا (Pseudocode کامل با Prisma Interactive Transaction)
async spin(userId: string, campaignId: string, idempotencyKey: string) {
// 1) Idempotency check خارج از تراکنش سنگین (Fast Path)
const existing = await this.prisma.spin.findUnique({
where: { idempotencyKey },
include: { prize: true, userPrize: true },
});
if (existing) {
return this.toSpinResponse(existing); // نتیجه قبلی، بدون مصرف Chance جدید (AC-09)
}
return this.prisma.$transaction(async (tx) => {
// 2) قفل ردیف کمپین برای جلوگیری از رقابت روی وضعیت/بازه زمانی (اختیاری، اما برای سازگاری کامل مفید است)
const campaign = await tx.campaign.findUnique({ where: { id: campaignId } });
if (!campaign) throw new NotFoundException('CAMPAIGN_NOT_FOUND');
if (!this.availability.isCampaignAvailable(campaign)) {
throw new BadRequestException('CAMPAIGN_NOT_ACTIVE'); // AC-14
}
// 3) قفل ردیف UserCampaign با SELECT ... FOR UPDATE (از طریق $queryRaw، چون Prisma هنوز FOR UPDATE ندارد)
const [userCampaign] = await tx.$queryRaw<UserCampaignRow[]>`
SELECT * FROM user_campaigns
WHERE user_id = ${userId} AND campaign_id = ${campaignId}
FOR UPDATE
`;
const remaining = (userCampaign?.total_chances ?? 0) - (userCampaign?.used_chances ?? 0);
if (!userCampaign || remaining <= 0) {
throw new BadRequestException('NO_CHANCE_LEFT'); // AC-02
}
// 4) قفل تمام Prizeهای فعال این کمپین (ترتیب ثابت بر اساس id برای جلوگیری از Deadlock)
const prizes = await tx.$queryRaw<PrizeRow[]>`
SELECT * FROM prizes
WHERE campaign_id = ${campaignId} AND status = 'ACTIVE'
AND (stock_type = 'UNLIMITED' OR remaining_stock > 0)
ORDER BY id
FOR UPDATE
`;
if (prizes.length === 0) throw new ConflictException('NO_ACTIVE_PRIZE');
// 5) انتخاب Weighted Random - کاملاً سمت Backend (AC-05, AC-06)
const selectedPrize = this.weightedRandomSelector.select(prizes);
// 6) کاهش موجودی در صورت Limited بودن (Race-safe چون قبلاً FOR UPDATE گرفتهایم)
if (selectedPrize.stockType === 'LIMITED') {
await tx.prize.update({
where: { id: selectedPrize.id },
data: { remainingStock: { decrement: 1 } },
});
}
// 7) مصرف یک Chance (Ledger + Snapshot در همین تراکنش)
await tx.chanceTransaction.create({
data: { userId, campaignId, amount: -1, type: 'SPIN_CONSUME', referenceType: 'spin' },
});
await tx.userCampaign.update({
where: { id: userCampaign.id },
data: { usedChances: { increment: 1 } },
});
// 8) ثبت Spin
const spin = await tx.spin.create({
data: {
userId, campaignId, prizeId: selectedPrize.id,
idempotencyKey, status: 'SUCCESS',
ip: this.request.ip, userAgent: this.request.headers['user-agent'],
},
});
// 9) اگر Prize واقعی است (نه No-Prize)، رکورد UserPrize با کد یکتا تولید شود
let userPrize = null;
if (selectedPrize.type !== 'NO_PRIZE') {
const code = await this.prizeCodeGenerator.generateUnique(tx);
userPrize = await tx.userPrize.create({
data: {
userId, campaignId, prizeId: selectedPrize.id, spinId: spin.id,
code, status: 'NEW', wonAt: new Date(),
},
});
}
return { spin, prize: selectedPrize, userPrize,
remainingChances: remaining - 1 };
}, {
isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,
timeout: 10000,
}).then(async (result) => {
// 10) ارسال SMS خارج از تراکنش اصلی، Queue-based (AC-12: Fail شدن SMS نباید Spin را Rollback کند)
await this.smsQueue.add('send-spin-result', {
userId, campaignId, spinId: result.spin.id,
});
return this.toSpinResponse(result);
});
}
۹.۳ چرا این طراحی Race Condition را حل میکند
SELECT ... FOR UPDATEروی ردیفuser_campaignsباعث میشود دو Request همزمان از یک کاربر (یا حتی با Idempotency Key متفاوت ولی همزمان) بهصورت صفبندیشده (Serialized) روی همان ردیف پردازش شوند؛ درخواست دوم تا پایان تراکنش اول منتظر میماند و مقدارremainingرا بهروز میبیند.SELECT ... FOR UPDATEروی ردیفهایprizes(بهخصوص Prize کمموجودی مثل «فقط یک آیفون») از انتخاب همزمان همان Prize توسط دو Transaction جلوگیری میکند؛ Transaction دوم بعد از Commit اول، مقدارremaining_stockبهروزشده (احتمالاً صفر) را میبیند و آن Prize را از Pool خارج میکند.- ترتیب قفلگیری ثابت (ابتدا
user_campaigns، سپسprizesبه ترتیبid) از Deadlock بین تراکنشهای موازی جلوگیری میکند. - سطح Isolation
RepeatableRead(یا حتیSerializableبرای اطمینان بیشتر در Traffic بالا، با هزینه Retry بیشتر) تضمین میکند خواندنهای داخل تراکنش با داده Commitشده جدید Conflict کند و Prisma خطای Serialization بدهد که باید در لایه Controller با یک Retry محدود (مثلاً حداکثر ۲ بار) مدیریت شود. - Unique Constraint سطح دیتابیس روی
spins.idempotency_keyبهعنوان خط دفاعی دوم عمل میکند: حتی اگر منطق Idempotency در لایه Application به هر دلیل رد شود، دیتابیس از درج رکورد تکراری جلوگیری میکند و خطای Unique Violation باید به همان پاسخ «نتیجه قبلی» Map شود.
۹.۴ الگوریتم Weighted Random
class WeightedRandomSelector {
select(prizes: PrizeRow[]): PrizeRow {
const totalWeight = prizes.reduce((sum, p) => sum + p.weight, 0);
const rnd = crypto.randomInt(0, totalWeight); // 0 تا totalWeight-1، امن رمزنگارانه
let cursor = 0;
for (const prize of prizes) {
cursor += prize.weight;
if (rnd < cursor) return prize;
}
return prizes[prizes.length - 1]; // Fallback ایمنی برای خطای رند
}
}
استفاده از crypto.randomInt بهجای Math.random() الزامی است چون Math.random() بهلحاظ رمزنگارانه امن نیست و در تئوری قابل پیشبینی/سوءاستفاده است — نکته مهم برای Anti-Fraud.
۹.۵ تولید کد یکتای جایزه
class PrizeCodeGenerator {
async generateUnique(tx: PrismaTx): Promise<string> {
for (let i = 0; i < 5; i++) {
const code = `MNT-${crypto.randomBytes(4).toString('hex').toUpperCase()}`;
const exists = await tx.userPrize.findUnique({ where: { code } });
if (!exists) return code;
}
throw new InternalServerErrorException('CODE_GENERATION_FAILED');
}
}
Constraint یکتا بودن code نیز در سطح دیتابیس (@unique در بخش ۳) وجود دارد تا حتی در صورت Race نظری، درج دوم رد شود.
10. Idempotency
- Frontend قبل از هر Spin یک
UUID v4جدید تولید و به همراه Request ارسال میکند؛ همان مقدار تا دریافت پاسخ (موفق یا خطای قطعی) دوباره تولید نمیشود؛ فقط در صورت Timeout/قطع شبکه، همان Request با همان کلید Retry میشود (نه Request جدید). - سرور طبق الگوریتم بخش ۹.۲، ابتدا با
idempotencyKeyجستجو میکند؛ در صورت وجود، همان نتیجه Historical برگردانده میشود بدون اجرای مجدد منطق Spin. - Unique Index دیتابیسی روی این ستون، تضمین سطح پایینتر (Defense in Depth) در برابر شرایط Race شدید (دو Request دقیقاً همزمان با یک کلید که هر دو از فاز Fast-Path عبور کردهاند) است: دومی با خطای
P2002(Unique Violation) مواجه میشود که در Exception Filter به همان پاسخ نتیجه قبلی Map میشود. - در Frontend، دکمه Spin بلافاصله Disable و تا دریافت پاسخ قطعی (نه صرفاً پایان انیمیشن) دوباره فعال نمیشود؛ این مطابق بخش ۲۹ سند اصلی («کاربر نباید بدون مشخص شدن وضعیت Request قبلی، Spin جدید ایجاد کند») است.
11. ماژول SMS (raygansms.com) + صف BullMQ
۱۱.۱ Interface مستقل از Provider
export interface SmsProviderInterface {
send(mobile: string, message: string): Promise<{ providerMessageId: string; raw: unknown }>;
}
۱۱.۲ پیادهسازی RayganSmsProvider
راهگان پیامک (raygansms.com) یک وبسرویس ارسال پیامک ایرانی با API مبتنی بر REST/SOAP است. Adapter زیر ساختار عمومی چنین سرویسهایی را پیادهسازی میکند و کلید API/Line Number از Environment Variable خوانده میشود:
@Injectable()
export class RayganSmsProvider implements SmsProviderInterface {
private readonly baseUrl = 'https://api.raygansms.com'; // بر اساس مستندات رسمی Provider در زمان پیادهسازی تایید و تنظیم شود
constructor(private readonly http: HttpService, private readonly config: ConfigService) {}
async send(mobile: string, message: string) {
const response = await firstValueFrom(
this.http.post(`${this.baseUrl}/api/sms/send`, {
username: this.config.get('RAYGAN_SMS_USERNAME'),
password: this.config.get('RAYGAN_SMS_PASSWORD'),
// یا مدل Token-based بسته به نسخه API که در مستندات رسمی زمان اجرا تایید میشود
from: this.config.get('RAYGAN_SMS_LINE_NUMBER'),
to: mobile,
text: message,
}),
);
return { providerMessageId: response.data.messageId ?? response.data.id, raw: response.data };
}
}
نکته پیادهسازی: جزئیات دقیق Endpoint، احراز هویت (Username/Password در مقابل API Token) و ساختار Response راهگان پیامک باید هنگام شروع پیادهسازی از مستندات رسمی و بهروز در پنل
raygansms.comاستخراج و در این Adapter اعمال شود؛ ساختار Interface (SmsProviderInterface) بهگونهای طراحی شده که این جزئیات هیچ تاثیری روی بقیه سیستم نمیگذارد.
۱۱.۳ SMS Service + Queue (BullMQ)
@Injectable()
export class SmsService {
constructor(
@InjectQueue('sms') private smsQueue: Queue,
) {}
async enqueueOtp(mobile: string, code: string) {
const message = `کد ورود شما به گردونه شانس: ${code}`;
await this.smsQueue.add('otp', { mobile, message, type: 'OTP' }, {
attempts: 3, backoff: { type: 'exponential', delay: 2000 }, priority: 1, // OTP اولویت بالاتر
});
}
async enqueueSpinResult(spinId: string) { /* واکشی Spin+Prize و ساخت پیام از sms_template یا template پیشفرض */ }
}
@Processor('sms')
export class SmsProcessor extends WorkerHost {
constructor(private prisma: PrismaService, private provider: RayganSmsProvider) { super(); }
async process(job: Job) {
const { mobile, message, type, userId } = job.data;
const log = await this.prisma.smsLog.create({
data: { userId, mobile, type, message, provider: 'raygansms', status: 'PENDING' },
});
try {
const result = await this.provider.send(mobile, message);
await this.prisma.smsLog.update({
where: { id: log.id },
data: { status: 'SENT', providerResponse: result.raw as any },
});
} catch (err) {
await this.prisma.smsLog.update({
where: { id: log.id },
data: { status: 'FAILED', providerResponse: { error: String(err) } },
});
throw err; // برای فعال شدن Retry مکانیزم BullMQ
}
}
}
۱۱.۴ قوانین حیاتی
- ارسال SMS همیشه بعد از Commit موفق تراکنش Spin به صف اضافه میشود، هرگز داخل همان Transaction (AC-12).
- شکست ارسال SMS (حتی بعد از تمام Retryها) هرگز باعث تغییر وضعیت Spin یا Rollback هیچ دادهای نمیشود؛ صرفاً
sms_logs.status = FAILEDثبت میشود و از پنل ادمین (بخش SMS) قابل مشاهده و ارسال مجدد دستی است. - Templateهای پایه (
OTP,WINNING,NO_PRIZE,PRIZE_REMINDER) در جدول جدا یا Config قابل ویرایش از پنل ادمین نگهداری میشوند تا بدون Deploy مجدد قابل تغییر باشند؛prizes.sms_templateدر صورت وجود، بر Template پیشفرضWINNINGاولویت دارد (برای شخصیسازی پیام هر جایزه). - Rate Limit مخصوص OTP (بخش ۵) مستقل از Queue است و قبل از افزودن Job به صف اعمال میشود تا صف با درخواستهای تکراری/مخرب پر نشود.
12. ماژول Winner / User Prizes / کد جایزه
وضعیتهای UserPrize و گذار مجاز بین آنها
برای جوایز فیزیکی:
NEW → CONTACTED → DELIVERED
برای کدهای تخفیف/اعتبار:
ACTIVE_CODE → USED
↓
EXPIRED (بهصورت خودکار توسط Cron Job بر اساس prizes.expires_at یا user_prizes.won_at + مدت اعتبار)
- گذار وضعیتها فقط از پنل ادمین (برای فیزیکی) یا خودکار/توسط API فروشگاه در آینده (برای کد) انجام میشود؛ کاربر نهایی حق تغییر وضعیت جایزه خودش را ندارد.
PATCH /api/admin/user-prizes/:id/statusبا ثبت اجباریAdminLog.- Cron Job روزانه (
@nestjs/schedule,@Cron('0 0 * * *')) جوایز منقضیشده را بهEXPIREDتغییر میدهد و میتواند SMS یادآوری (PRIZE_REMINDER) قبل از انقضا ارسال کند (مثلاً ۲۴ ساعت قبل).
Import مجموعه کدهای آماده (خارج از MVP، معماریپذیر طبق بخش ۲۱ سند اصلی)
جدول اضافه پیشنهادی برای فاز بعد (فعلاً پیادهسازی نمیشود ولی مدل داده اجازه میدهد):
model PrizeCodePool {
id String @id @default(uuid())
prizeId String @map("prize_id")
code String @unique
isUsed Boolean @default(false) @map("is_used")
usedByUserPrizeId String? @unique @map("used_by_user_prize_id")
createdAt DateTime @default(now()) @map("created_at")
@@map("prize_code_pools")
}
در این حالت، PrizeCodeGenerator بهجای تولید رندوم، یک کد isUsed=false از این جدول با FOR UPDATE SKIP LOCKED برمیدارد — دقیقاً همان الگوی Lock که در بخش ۹ توضیح داده شد.
13. ماژول File Storage (Local، قابل تعویض به S3)
طبق پاسخ کارفرما، فاز فعلی روی Local Storage پیاده میشود اما تصمیم Provider ابری هنوز باز است؛ بنابراین از یک Interface استفاده میشود تا مهاجرت آینده صفر-تغییر در Business Logic باشد:
export interface StorageProviderInterface {
upload(file: Buffer, options: { folder: string; filename: string; mimeType: string }): Promise<{ url: string; path: string }>;
delete(path: string): Promise<void>;
}
@Injectable()
export class LocalStorageProvider implements StorageProviderInterface {
private readonly root = process.env.STORAGE_LOCAL_ROOT || './uploads';
private readonly publicBaseUrl = process.env.STORAGE_PUBLIC_URL; // مثلا https://api.example.com/uploads
async upload(file: Buffer, { folder, filename, mimeType }) {
const dir = path.join(this.root, folder);
await fs.promises.mkdir(dir, { recursive: true });
const finalName = `${Date.now()}-${crypto.randomUUID()}${path.extname(filename)}`;
await fs.promises.writeFile(path.join(dir, finalName), file);
const relPath = `${folder}/${finalName}`;
return { url: `${this.publicBaseUrl}/${relPath}`, path: relPath };
}
async delete(relPath: string) {
await fs.promises.unlink(path.join(this.root, relPath)).catch(() => undefined);
}
}
- فایلهای آپلودی (تصویر جایزه، لوگو، بنر کمپین، تصویر Header) از طریق
POST /api/admin/uploadsباmultipart/form-data(استفاده از@UseInterceptors(FileInterceptor)) دریافت و مسیر برگرداندهشده در فیلد مربوطه (campaigns.logo,prizes.image, ...) ذخیره میشود؛ خود Entity فقط URL/Path را نگه میدارد. - محدودیتهای اعتبارسنجی: نوع فایل (
jpg/png/webp)، حداکثر حجم (مثلاً ۲ مگابایت برای آیکون/لوگو، ۵ مگابایت برای بنر)، و بررسی Content-Type واقعی فایل (نه فقط پسوند) برای جلوگیری از Upload فایل مخرب. - Nginx مسیر
/uploadsرا مستقیم از دیسک سرو میکند (بدون عبور از Node.js) برای کارایی بهتر؛ در فاز مهاجرت به S3، همین مسیر URL به CDN/Bucket تغییر میکند و کد Backend فقط Provider را درStorageModuleتعویض میکند (useClass: S3StorageProvider).
14. مستندات کامل API — کاربر
پایه آدرس: /api
Authentication
| Method | Route | Body | Response |
|---|---|---|---|
| POST | /auth/send-otp |
{ mobile: string } |
{ success: true, expiresIn: 120 } |
| POST | /auth/verify-otp |
{ mobile: string, code: string } |
{ accessToken: string, user: { id, mobile, name } } |
Campaign
| Method | Route | توضیح |
|---|---|---|
| GET | /campaigns/:slug |
اطلاعات عمومی کمپین (عنوان، توضیح، لوگو، بنر، CTA، قوانین، wheelDesign، settings) |
| GET | /campaigns/:slug/wheel |
لیست Segmentهای گردونه (فقط Prizeهای Active با موجودی — بدون افشای weight واقعی؛ صرفاً ترتیب، رنگ، عنوان، تصویر) |
نکته امنیتی مهم: پاسخ
/wheelهرگز فیلدweightواقعی جوایز را برنمیگرداند تا کاربر امکان محاسبه دقیق احتمال برد و سوءاستفاده احتمالی (مثلاً تشخیص لحظه بازگشت موجودی) را نداشته باشد.
User (نیازمند Authentication)
| Method | Route | توضیح |
|---|---|---|
| GET | /me |
اطلاعات کاربر جاری |
| GET | /me/chances?campaignId= |
{ totalChances, usedChances, remainingChances } |
| GET | /me/prizes |
تاریخچه جوایز کاربر (user_prizes با join به prizes) |
Spin (نیازمند Authentication)
| Method | Route | Body | Response |
|---|---|---|---|
| POST | /campaigns/:id/spin |
{ idempotencyKey: string } |
مطابق بخش ۹.۱ |
کدهای خطای استاندارد (برای Mapping به UI طبق بخش ۲۹ سند اصلی)
| کد HTTP | errorCode | معنی | UI پیشنهادی |
|---|---|---|---|
| 400 | NO_CHANCE_LEFT |
شانس کافی نیست | Modal: «شانس شما تمام شده» + دکمه جوایز من |
| 400 | CAMPAIGN_NOT_STARTED |
کمپین شروع نشده | نمایش Countdown |
| 400 | CAMPAIGN_FINISHED |
کمپین تمام شده | صفحه پایان کمپین |
| 400 | CAMPAIGN_PAUSED |
کمپین موقتاً متوقف | پیام موقت + Retry بعدی |
| 401 | UNAUTHORIZED |
Token نامعتبر/منقضی | ریدایرکت به ورود |
| 409 | NO_ACTIVE_PRIZE |
هیچ جایزه فعالی در Pool نیست | پیام «گردونه موقتاً در دسترس نیست» |
| 429 | TOO_MANY_REQUESTS |
Rate Limit | پیام «کمی صبر کنید» |
| 500 | INTERNAL_ERROR |
خطای سرور | پیام عمومی + امکان تلاش مجدد |
| — | Network Error (Client-side) | قطع اینترنت | عدم ساخت Spin جدید، نمایش وضعیت آخرین Request در حافظه محلی (بخش ۱۷.۳) |
15. مستندات کامل API — ادمین
پایه آدرس: /api/admin — همه نیازمند JWT ادمین (JwtAdminGuard) + RolesGuard برای عملیات حساس (مثلاً فقط SUPER_ADMIN مجاز به تغییر Role ادمین دیگر).
Admin Auth
| Method | Route |
|---|---|
| POST | /admin/auth/login |
| POST | /admin/auth/refresh |
| POST | /admin/auth/logout |
Campaigns
| Method | Route |
|---|---|
| GET / POST | /admin/campaigns |
| GET / PATCH / DELETE | /admin/campaigns/:id |
Prizes
| Method | Route |
|---|---|
| GET / POST | /admin/campaigns/:campaignId/prizes |
| PATCH / DELETE | /admin/prizes/:id |
| PATCH | /admin/prizes/:id/stock |
Users
| Method | Route |
|---|---|
| GET | /admin/users (Search, Filter, Pagination) |
| GET | /admin/users/:id (شامل کمپینها، شانس، تاریخچه Spin، جوایز) |
| PATCH | /admin/users/:id/block |
| POST | /admin/users/:userId/campaigns/:campaignId/chances |
Spins
| Method | Route |
|---|---|
| GET | /admin/spins (Search/Filter بر اساس mobile, campaign, prize, date, status + Pagination) |
| GET | /admin/spins/export (Excel) |
Winners
| Method | Route |
|---|---|
| GET | /admin/user-prizes |
| PATCH | /admin/user-prizes/:id/status |
| GET | /admin/user-prizes/export |
SMS
| Method | Route |
|---|---|
| GET | /admin/sms-logs |
| POST | /admin/sms-logs/:id/resend |
Reports
| Method | Route |
|---|---|
| GET | /admin/reports/dashboard (تمام KPIهای بخش ۱۸ سند اصلی) |
| GET | /admin/reports/participation?from=&to=&campaignId= |
| GET | /admin/reports/export?...&format=xlsx |
Audit Log
| Method | Route |
|---|---|
| GET | /admin/audit-logs (Filter بر اساس admin, entityType, date) |
Uploads
| Method | Route |
|---|---|
| POST | /admin/uploads (multipart) |
تمام Endpointهای Listing از یک ساختار پاسخ استاندارد Pagination استفاده میکنند:
{
"data": [ /* items */ ],
"meta": { "page": 1, "pageSize": 20, "total": 134, "totalPages": 7 }
}
16. ساختار پروژه Admin Panel (Vue 3 + Ant Design Vue + Pinia)
apps/admin/
├── src/
│ ├── main.ts
│ ├── App.vue
│ ├── router/
│ │ └── index.ts # Route Guard بر اساس Pinia authStore
│ ├── stores/
│ │ ├── auth.store.ts
│ │ ├── campaign.store.ts
│ │ ├── prize.store.ts
│ │ ├── user.store.ts
│ │ ├── spin.store.ts
│ │ ├── winner.store.ts
│ │ ├── sms.store.ts
│ │ └── report.store.ts
│ ├── services/
│ │ └── api/ # axios instance + هر ماژول یک فایل (campaign.api.ts, prize.api.ts, ...)
│ ├── layouts/
│ │ └── AdminLayout.vue # a-layout با Sider (Ant Design Vue) + منوی بخش ۱۷ سند اصلی
│ ├── views/
│ │ ├── dashboard/DashboardView.vue
│ │ ├── campaigns/CampaignListView.vue
│ │ ├── campaigns/CampaignFormView.vue # شامل انتخابگر طرح گردونه (بخش ۱۸)
│ │ ├── prizes/PrizeListView.vue
│ │ ├── prizes/PrizeFormView.vue
│ │ ├── users/UserListView.vue
│ │ ├── users/UserDetailView.vue
│ │ ├── spins/SpinListView.vue
│ │ ├── winners/WinnerListView.vue
│ │ ├── sms/SmsLogListView.vue
│ │ ├── reports/ReportView.vue
│ │ ├── audit/AuditLogView.vue
│ │ └── settings/SettingsView.vue
│ └── components/
│ ├── wheel-preview/WheelDesignPicker.vue # Thumbnail هر ۵ طرح برای انتخاب در فرم کمپین
│ ├── charts/SpinsByDayChart.vue
│ └── charts/PrizeDistributionChart.vue
نمونه Store (Pinia + Composition API)
// stores/campaign.store.ts
export const useCampaignStore = defineStore('campaign', () => {
const campaigns = ref<Campaign[]>([]);
const loading = ref(false);
async function fetchAll(params: CampaignFilterParams) {
loading.value = true;
try {
const { data } = await campaignApi.list(params);
campaigns.value = data.data;
return data.meta;
} finally { loading.value = false; }
}
async function create(payload: CreateCampaignDto) {
const { data } = await campaignApi.create(payload);
campaigns.value.unshift(data);
return data;
}
return { campaigns, loading, fetchAll, create };
});
الگوی UI با Ant Design Vue
- لیستها:
<a-table>باpagination,filtersسمت سرور (Server-side)، دکمه Export. - فرمها:
<a-form>+<a-form-item>با Validation Rules،<a-upload>برای تصاویر (اتصال به/admin/uploads). - Dashboard:
<a-row>/<a-col>+<a-card>برای KPI، نمودار با ECharts داخل<a-card>. - Layout:
<a-layout>با<a-layout-sider>(منوی بخش ۱۷ سند اصلی: Dashboard, Campaigns, Prizes, Users, Spins, Winners, Chance Management, SMS, Reports, Settings, Audit Log) و<a-layout-header>شامل نام ادمین و دکمه خروج. - Auth Interceptor در Axios: در صورت ۴۰۱، تلاش برای Refresh Token و در صورت شکست، ریدایرکت به صفحه Login و پاکسازی Pinia.
17. ساختار پروژه User Client (Vue 3 + Wheel Component)
apps/client/
├── src/
│ ├── main.ts
│ ├── App.vue
│ ├── router/
│ ├── stores/
│ │ ├── auth.store.ts # OTP flow + JWT در localStorage/secure cookie
│ │ ├── campaign.store.ts
│ │ ├── spin.store.ts # مدیریت idempotencyKey + وضعیت Spin در حال انجام
│ │ └── prizes-history.store.ts
│ ├── services/api/
│ ├── views/
│ │ ├── LoginView.vue # ورود شماره موبایل
│ │ ├── OtpView.vue
│ │ ├── WheelView.vue # صفحه اصلی گردونه (بخش ۱۳/۱۴ سند اصلی)
│ │ ├── ResultModal.vue
│ │ └── MyPrizesView.vue
│ └── components/
│ └── wheel/
│ ├── WheelBase.vue # منطق مشترک: چرخش، محاسبه زاویه توقف، Event
│ ├── designs/ClassicCasinoWheel.vue
│ ├── designs/NeonCyberWheel.vue
│ ├── designs/MinimalFlatWheel.vue
│ ├── designs/LuxuryGoldWheel.vue
│ └── designs/PlayfulCartoonWheel.vue
۱۷.۱ الگوی انتخاب طرح گردونه در Runtime
WheelView.vue مقدار campaign.wheelDesign را از API میگیرد و کامپوننت متناظر را بهصورت پویا Render میکند؛ منطق چرخش (محاسبه زاویه بر اساس Segment برگرداندهشده از Backend، شمارش دور، مدت انیمیشن) در WheelBase.vue مشترک است و هر طرح فقط ظاهر (رنگ، فونت، افکت، تصاویر Segment) را override میکند:
<script setup lang="ts">
const designComponents = {
CLASSIC_CASINO: ClassicCasinoWheel,
NEON_CYBER: NeonCyberWheel,
MINIMAL_FLAT: MinimalFlatWheel,
LUXURY_GOLD: LuxuryGoldWheel,
PLAYFUL_CARTOON: PlayfulCartoonWheel,
};
const ActiveWheel = computed(() => designComponents[campaign.value.wheelDesign]);
</script>
<template>
<component
:is="ActiveWheel"
:segments="segments"
:target-segment="spinResult?.prize?.segment"
:spinning="isSpinning"
@spin-finished="onSpinFinished"
/>
</template>
۱۷.۲ رفتار Spin در Frontend (طبق بخش ۱۴ سند اصلی، بدون تغییر)
- لمس دکمه «بچرخون» → Disable فوری دکمه.
- نمایش Loading کوتاه (اسپینر کوچک روی دکمه، نه تمامصفحه).
- تولید/استفاده از
idempotencyKeyموجود در Store و ارسالPOST /spin. - دریافت پاسخ شامل
prize.segment. - اجرای انیمیشن: چند دور کامل (مثلاً ۵ دور) + توقف دقیق روی زاویه مرکز Segment هدف (محاسبه با
360/segmentsCount * targetIndex + offset تصادفی کوچک داخل همان Segment برای طبیعیتر شدن). - تا پایان انیمیشن (
transitionendیا تایمر معادل)، دکمه غیرفعال میماند. - نمایش Modal نتیجه (
ResultModal.vue) بعد از پایان انیمیشن، نه همزمان با دریافت پاسخ API. - بعد از بستن Modal: بهروزرسانی
remainingChancesدر Store؛ اگر باقی مانده، CTA «یک شانس دیگه دارم»، در غیر این صورت «مشاهده جوایز من».
۱۷.۳ مدیریت خطای شبکه و Idempotency در Frontend
// spin.store.ts
async function spin() {
if (isSpinning.value) return; // جلوگیری از دابلکلیک
isSpinning.value = true;
const key = pendingIdempotencyKey.value ?? crypto.randomUUID();
pendingIdempotencyKey.value = key; // Persist در Store (و اختیاری sessionStorage) تا رفرش صفحه هم آن را نبازد
try {
const { data } = await spinApi.spin(campaignId.value, { idempotencyKey: key });
lastResult.value = data;
pendingIdempotencyKey.value = null; // فقط بعد از موفقیت قطعی پاک شود
} catch (err) {
if (isNetworkError(err)) {
// کلید را نگه میداریم؛ کاربر با دکمه "بررسی نتیجه" یا Retry خودکار همان Request را دوباره میفرستد
showNetworkErrorState();
} else {
pendingIdempotencyKey.value = null; // خطای قطعی (مثلا NO_CHANCE_LEFT) → کلید جدید برای تلاش بعدی
handleBusinessError(err);
}
} finally {
isSpinning.value = false;
}
}
این الگو دقیقاً پیادهسازی قانون بخش ۲۹ سند اصلی است: «در خطای شبکه، کاربر نباید بدون مشخص شدن وضعیت Request قبلی، Spin جدید ایجاد کند.»
18. پنج طرح گردونه (Wheel Designs) قابل انتخاب توسط ادمین
هنگام ساخت یا ویرایش کمپین، ادمین از یک WheelDesignPicker (Thumbnail هر طرح + پیشنمایش زنده) یکی از پنج طرح زیر را انتخاب میکند؛ مقدار در campaigns.wheel_design ذخیره و همان طرح دقیقاً برای همه کاربران آن کمپین نمایش داده میشود. رنگ Segment از prizes.segment_color میآید؛ هر طرح فقط چارچوب بصری (Frame، Pointer، Hub، تایپوگرافی، افکتها، پسزمینه) را تعیین میکند.
طرح ۱ — Classic Casino (کلاسیک کازینویی)
- حسوحال: لوکس سنتی، اعتمادساز، مناسب کمپینهای عمومی و فروشگاهی.
- پالت: پسزمینه قرمز تیره/طلایی، حاشیه گردونه با حبابهای نور طلایی چشمکزن (LED Bulb Ring).
- Pointer: فلش سهبعدی طلایی رو به پایین.
- Hub مرکزی: دکمه دایرهای طلایی با متن «SPIN» یا
cta_textکمپین. - تایپوگرافی: فونت ضخیم Serif برای اعداد/جوایز.
- افکت برد: Confetti طلایی + صدای سکه (اختیاری).
- بهترین کاربرد: کمپینهای فروشگاهی، جشنوارههای خرید.
طرح ۲ — Neon Cyber (نئون سایبری)
- حسوحال: مدرن، جوانپسند، تکنولوژیک؛ مناسب برندهای دیجیتال، اپلیکیشن، بازی.
- پالت: پسزمینه مشکی/بنفش تیره، خطوط Neon Glow (فیروزهای/صورتی) دور گردونه و مرزهای Segment.
- Pointer: مثلث نئونی با افکت Glow پالسی.
- Hub مرکزی: دایره شیشهای (Glassmorphism) با افکت Pulse هنگام Spin.
- تایپوگرافی: فونت Geometric Sans با افکت Glow روی متن برنده.
- افکت برد: انفجار ذرات نئونی + لرزش صفحه سبک (Screen Shake).
- بهترین کاربرد: اپلیکیشنهای موبایل، گیمینگ، کمپینهای Tech.
طرح ۳ — Minimal Flat (مینیمال تخت)
- حسوحال: تمیز، حرفهای، سریعبارگذاری؛ مناسب برندهایی که هویت بصری ساده و مدرن دارند (فینتک، B2B، خدماتی).
- پالت: پسزمینه سفید/خاکستری روشن، Segmentها با رنگهای Flat تعریفشده در تنظیمات جایزه، بدون Gradient/سایه سنگین.
- Pointer: مثلث ساده تکرنگ (رنگ اصلی برند از
settings.primaryColor). - Hub مرکزی: دایره تخت با لوگوی کمپین.
- تایپوگرافی: فونت Sans-Serif ظریف، Line-height باز.
- افکت برد: Fade + Scale ساده بدون افکت گرافیکی سنگین (Performance بالا در موبایلهای ضعیف).
- بهترین کاربرد: کمپینهای Corporate، اپلیکیشنهای بانکی/فینتک، برندهایی با اولویت سرعت بارگذاری.
طرح ۴ — Luxury Gold (لوکس طلایی/پرمیوم)
- حسوحال: خاص، گرانقیمت، VIP؛ مناسب کمپینهای جواهرات، خودرو، جوایز بزرگ.
- پالت: پسزمینه مشکی مات با بافت مرمر/طلایی ظریف، Segmentهای مشکی/طلایی متناوب با خطوط باریک طلایی جداکننده.
- Pointer: الماس یا تاج کوچک طلایی.
- Hub مرکزی: دایره طلایی با افکت درخشش آهسته (Shimmer Animation مداوم، نه فقط هنگام Spin).
- تایپوگرافی: فونت Serif ظریف (شبیه برندهای لوکس)، Letter-spacing باز.
- افکت برد: درخشش نور از مرکز به بیرون + صدای ملایم (اختیاری).
- بهترین کاربرد: کمپینهای VIP، جوایز باارزش بالا، برندهای پرمیوم.
طرح ۵ — Playful Cartoon (کارتونی و بازیگوش)
- حسوحال: شاد، رنگارنگ، خانوادهمحور؛ مناسب کمپینهای کودک، فستفود، سرگرمی، تخفیفهای عمومی.
- پالت: رنگهای اشباعشده و شاد (نارنجی، فیروزهای، زرد، صورتی)، Segmentها با الگوی نقطهای/موجی کارتونی.
- Pointer: شخصیت کوچک انیمیشنی (مثلاً دست اشارهگر یا موشک) با بونس ساده (Bounce) هنگام توقف.
- Hub مرکزی: دکمه بزرگ گرد با افکت Squish (فشردهشدن) هنگام لمس.
- تایپوگرافی: فونت گرد و ضخیم (Rounded/Bubbly).
- افکت برد: بادکنک/کانفتی رنگی + انیمیشن پرش شخصیت.
- بهترین کاربرد: کمپینهای فستفود، سرگرمی، مناسبتی (تولد، اعیاد)، مخاطب خانواده و کودک.
پیادهسازی فنی مشترک همه طرحها
- همه پنج کامپوننت (
ClassicCasinoWheel.vue,NeonCyberWheel.vue, ...) یک Contract یکسان Props/Emits دارند تاWheelView.vueبدون شرطگذاری اضافه، هرکدام را Render کند:
interface WheelDesignProps {
segments: { id: string; title: string; image?: string; color: string }[];
targetSegmentIndex: number | null; // از پاسخ Backend
spinning: boolean;
}
// emits: ['spin-finished']
- محاسبه زاویه دقیق توقف (منطق ریاضی چرخش) در
WheelBase.vueمشترک نگه داشته میشود و بهصورت Composable (useWheelRotation()) در تمام ۵ طرح Import میشود؛ فقط SVG/CSS/افکت هر طرح متفاوت است. این کار از تکرار منطق حساس (که مستقیماً روی صحت نمایش نتیجه Backend اثر دارد) در ۵ فایل جلوگیری میکند. - هر طرح دارای نسخه Reduced Motion (برای کاربرانی که
prefers-reduced-motionرا در مرورگر فعال کردهاند) است: افکتهای پرهزینه (Glow پیوسته، Shimmer، ذرات) غیرفعال و فقط چرخش اصلی نمایش داده میشود. - تمام ۵ طرح باید در viewport موبایل (حداقل عرض ۳۲۰px) بدون Horizontal Scroll و بدون افت کیفیت رندر شوند (الزام بخش ۳۱ سند اصلی)؛ توصیه میشود گردونه بهصورت SVG با
viewBoxنسبی (نه اندازه ثابت پیکسلی) پیادهسازی شود.
19. Security و Anti-Fraud
| کنترل | پیادهسازی فنی |
|---|---|
| OTP Verification | هششده در دیتابیس، TTL ۲ دقیقه، محدودیت تلاش (بخش ۵) |
| Authentication Token | JWT جدا برای User و Admin با type claim متفاوت؛ Guard اختصاصی هرکدام |
| Server-Side Spin | تمام منطق در SpinEngineService؛ هیچ مقدار Prize/Winner از Body ورودی خوانده نمیشود |
| Idempotency | idempotency_key یکتا در دیتابیس + بررسی Application-level (بخش ۹، ۱۰) |
| IP / User-Agent Logging | ثبت روی هر Spin از req.ip (پشت Nginx با X-Forwarded-For صحیح تنظیمشده) و req.headers['user-agent'] |
| محدودیت درخواست (Rate Limiting) | @nestjs/throttler سراسری + Throttle اختصاصی روی /spin و /send-otp (بخش ۲۰) |
| جلوگیری از Concurrent Spin | SELECT ... FOR UPDATE روی user_campaigns (بخش ۹.۳) — عملاً هر کاربر در هر لحظه فقط یک Spin در حال پردازش دارد |
| Transaction برای Prize Inventory | SELECT ... FOR UPDATE روی prizes + Prisma Interactive Transaction (بخش ۹) |
| Admin Audit Log | Interceptor سراسری (AuditInterceptor) روی تمام Routeهای PATCH/POST/DELETE پنل ادمین که مقدار قبل/بعد را در admin_logs ثبت میکند |
| عدم افشای Weight به کلاینت | Endpoint عمومی گردونه (/campaigns/:slug/wheel) فیلد weight را Strip میکند (DTO جدا از Entity، بخش ۱۴) |
| Helmet / CORS | helmet() فعال، CORS محدود به Origin دقیق دو Frontend (Admin/Client) در Production |
| SQL Injection | استفاده انحصاری از Prisma Client یا $queryRaw با Parameterized Query (Tagged Template)؛ هرگز String Concatenation |
| XSS | Escape خودکار Vue در Template؛ عدم استفاده از v-html برای محتوای واردشده توسط کاربر/ادمین (مثلاً description کمپین با Sanitize مثل DOMPurify در صورت نیاز به HTML محدود) |
20. Rate Limiting
پیکربندی @nestjs/throttler بهصورت چندلایه:
ThrottlerModule.forRoot([
{ name: 'default', ttl: 60000, limit: 60 }, // سراسری: ۶۰ درخواست در دقیقه هر IP
]);
Override اختصاصی روی Endpointهای حساس:
| Endpoint | محدودیت |
|---|---|
POST /auth/send-otp |
۱ درخواست در ۶۰ ثانیه هر شماره موبایل + ۵ درخواست در ساعت هر IP |
POST /auth/verify-otp |
۵ تلاش در ۵ دقیقه هر شماره موبایل |
POST /campaigns/:id/spin |
۱۰ درخواست در دقیقه هر کاربر (فراتر از نیاز واقعی UX ولی سد در برابر Script/Bot) |
POST /admin/auth/login |
۵ تلاش در ۱۵ دقیقه هر IP (جلوگیری از Brute-force پنل مدیریت) |
21. Environment Variables
# App
NODE_ENV=production
PORT=3000
# Database
DATABASE_URL=postgresql://user:pass@localhost:5432/lucky_wheel
# JWT
JWT_USER_SECRET=change-me
JWT_USER_EXPIRES_IN=7d
JWT_ADMIN_ACCESS_SECRET=change-me
JWT_ADMIN_ACCESS_EXPIRES_IN=1h
JWT_ADMIN_REFRESH_SECRET=change-me
JWT_ADMIN_REFRESH_EXPIRES_IN=7d
# Redis / BullMQ
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# SMS - raygansms.com
RAYGAN_SMS_USERNAME=xxxx
RAYGAN_SMS_PASSWORD=xxxx
RAYGAN_SMS_LINE_NUMBER=xxxx
RAYGAN_SMS_API_BASE_URL=https://api.raygansms.com
# File Storage (Local - فاز فعلی)
STORAGE_DRIVER=local
STORAGE_LOCAL_ROOT=./uploads
STORAGE_PUBLIC_URL=https://api.example.com/uploads
# CORS
CORS_ADMIN_ORIGIN=https://admin.example.com
CORS_CLIENT_ORIGIN=https://wheel.example.com
کلیدهای واقعی سرویس راهگان پیامک (نام کاربری/رمز/شماره خط) باید از پنل
raygansms.comدریافت و هرگز در Git Commit نشوند (.envدر.gitignore).
22. Docker / Deployment
# docker-compose.yml (Dev)
services:
postgres:
image: postgres:15
environment:
POSTGRES_DB: lucky_wheel
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports: ["5432:5432"]
volumes: ["pg_data:/var/lib/postgresql/data"]
redis:
image: redis:7-alpine
ports: ["6379:6379"]
api:
build: ./apps/api
depends_on: [postgres, redis]
env_file: ./apps/api/.env
ports: ["3000:3000"]
volumes: ["./apps/api/uploads:/app/uploads"]
admin:
build: ./apps/admin
ports: ["5173:80"]
client:
build: ./apps/client
ports: ["5174:80"]
volumes:
pg_data:
مراحل Deploy پیشنهادی
pnpm installدر Root (Workspace).pnpm --filter api prisma migrate deploy(اجرای Migrationها روی دیتابیس Production).- Build سه اپلیکیشن (
pnpm --filter api build,--filter admin build,--filter client build). - Serve
admin/clientبهصورت استاتیک از پشت Nginx (یا هر CDN). - اجرای
apiبا PM2/Docker با حداقل ۲ Instance پشت Load Balancer برای Zero-downtime Deploy (با توجه به اینکه Session در JWT است، نیازی به Sticky Session نیست). - اجرای Worker BullMQ بهصورت Process/Instance جدا از API اصلی (مقیاسپذیری مستقل صف SMS از ترافیک HTTP).
23. استراتژی تست و Mapping با Acceptance Criteria
سطوح تست
- Unit Test:
WeightedRandomSelector,ChanceService,PrizeCodeGenerator,CampaignAvailabilityService(Jest، بدون دیتابیس واقعی — Prisma Mock). - Integration Test:
SpinEngineServiceروی دیتابیس Test واقعی (Postgres در Docker) شامل سناریوهای Concurrency (اجرای موازی چند Promise برای شبیهسازی Race Condition آیفون بخش ۹ سند اصلی). - E2E Test: با
supertestروی کل مسیر OTP → Login → Spin → Result برای هر دو نقش. - Load Test: k6 یا Artillery برای اطمینان از پایداری Lock Contention روی
prizes/user_campaignsتحت بار بالا (مثلاً ۵۰۰ Spin همزمان روی یک کمپین با یک جایزه کمموجودی).
Mapping مستقیم AC-01 تا AC-14 (سند اصلی) به تست
| AC | تست متناظر |
|---|---|
| AC-01 | E2E: تلاش Spin بدون JWT معتبر → 401 |
| AC-02 | Integration: کاربر با remaining=0 → 400 NO_CHANCE_LEFT |
| AC-03 | Integration: بررسی usedChances دقیقاً ۱ واحد افزایش بعد از هر Spin موفق |
| AC-04 | E2E: چند بار GET /me/chances پشتسرهم بدون تغییر مقدار |
| AC-05 / AC-06 | Unit + Code Review: هیچ Controller مقدار Prize را از Body نمیخواند؛ فقط idempotencyKey ورودی است |
| AC-07 | Integration: Prize با remainingStock=0 هرگز در نتیجه ۱۰۰ اجرای متوالی Selector ظاهر نشود |
| AC-08 | Load Test: ۱۰۰ Request موازی روی Prize با موجودی ۱ → دقیقاً ۱ برنده، remainingStock هرگز منفی |
| AC-09 | Integration: ارسال دو Request با یک idempotencyKey → پاسخ یکسان، usedChances فقط یک بار افزایش |
| AC-10 | بررسی وجود رکورد Spin برای هر سناریوی بالا با ip/userAgent پر |
| AC-11 | Integration: POST chances ادمین → بررسی وجود AdminLog متناظر |
| AC-12 | Integration: Mock کردن Provider SMS برای پرتاب Exception → بررسی اینکه Spin/UserPrize همچنان ثبتشده باقی میماند |
| AC-13 | E2E: Login مجدد کاربر → GET /me/prizes شامل جوایز قبلی |
| AC-14 | Integration: تلاش Spin روی کمپین با endAt گذشته → 400 CAMPAIGN_FINISHED |
24. جمعبندی و مسیر توسعه آینده
این سند، تمام محدوده MVP بخش ۳۴ سند اصلی را با معماری مشخص، Concurrency-Safe و قابل تست پوشش میدهد. طراحی بهگونهای انجام شده که موارد بخش ۳۳ سند اصلی (خارج از MVP) بدون تغییر ساختاری بزرگ قابل افزودن باشند:
- Referral / دریافت شانس با خرید: فقط نیازمند یک
ChanceTxnTypeجدید و یک Endpoint/Webhook جدید است؛ موتور Chance Ledger از قبل عمومی طراحی شده. - اتصال API فروشگاه / Webhook: با افزودن ماژول
WebhookModuleکه رویدادهایspin.completed,prize.wonرا Publish میکند (با BullMQ یا یک Event Emitter داخلی NestJS،@nestjs/event-emitter). - QR Code / Multi-Brand / White Label: با افزودن سطح
BrandبالایCampaignدر مدل داده (یکbrand_idروی جدول کمپین) بدون نیاز به تغییر Spin Engine. - طرحهای گردونه بیشتر از ۵ مورد: به لطف الگوی
WheelDesignPropsمشترک (بخش ۱۸)، افزودن طرح ششم صرفاً یک کامپوننت Vue جدید + یک مقدار Enum جدید است، بدون تغییر در Backend یا در چهار طرح دیگر.
مرجع اصلی این سند: Lucky Wheel Lottery Spec v1.0 (فایل مرجع کارفرما) + تصمیمات فنی این جلسه (NestJS/Prisma/PostgreSQL، Vue3/Pinia/Ant Design Vue، BullMQ+Redis، raygansms.com، Local Storage قابل مهاجرت، ۵ طرح گردونه انتخابی).