Files
Lucky_Wheel/docs/Lucky_Wheel_Implementation_Blueprint.md
T
2026-09-10 01:58:29 +03:30

76 KiB
Raw Blame History

داکیومنت فنی پیاده‌سازی سامانه گردونه شانس و قرعه‌کشی

نسخه سند: 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)


فهرست مطالب

  1. مقدمه
  2. Tech Stack نهایی
  3. معماری کلی و ساختار Monorepo
  4. مدل داده — Prisma Schema کامل
  5. ساختار پروژه Backend (NestJS)
  6. ماژول Authentication (OTP)
  7. ماژول Campaign
  8. ماژول Prize
  9. ماژول Chance و Chance Ledger
  10. Spin Engine — الگوریتم اجرایی و کنترل Concurrency
  11. Idempotency
  12. ماژول SMS (raygansms.com) + صف BullMQ
  13. ماژول Winner / User Prizes / کد جایزه
  14. ماژول File Storage (Local، قابل تعویض به S3)
  15. مستندات کامل API — کاربر
  16. مستندات کامل API — ادمین
  17. ساختار پروژه Admin Panel (Vue 3 + Ant Design Vue + Pinia)
  18. ساختار پروژه User Client (Vue 3 + Wheel Component)
  19. پنج طرح گردونه (Wheel Designs) قابل انتخاب توسط ادمین
  20. Security و Anti-Fraud
  21. Rate Limiting
  22. Environment Variables
  23. Docker / Deployment
  24. استراتژی تست و Mapping با Acceptance Criteria
  25. جمع‌بندی و مسیر توسعه آینده

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/campaigns
  • GET /api/admin/campaigns (با Pagination/Filter بر اساس status)
  • GET /api/admin/campaigns/:id
  • PATCH /api/admin/campaigns/:id
  • DELETE /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/prizes
  • PATCH /api/admin/prizes/:id
  • DELETE /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 دیتابیسی این دو کار را همزمان انجام می‌دهد:

  1. درج یک ردیف در chance_transactions (Ledger — تغییرناپذیر، هرگز Update/Delete نمی‌شود).
  2. به‌روزرسانی 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 (طبق بخش ۱۴ سند اصلی، بدون تغییر)

  1. لمس دکمه «بچرخون» → Disable فوری دکمه.
  2. نمایش Loading کوتاه (اسپینر کوچک روی دکمه، نه تمام‌صفحه).
  3. تولید/استفاده از idempotencyKey موجود در Store و ارسال POST /spin.
  4. دریافت پاسخ شامل prize.segment.
  5. اجرای انیمیشن: چند دور کامل (مثلاً ۵ دور) + توقف دقیق روی زاویه مرکز Segment هدف (محاسبه با 360/segmentsCount * targetIndex + offset تصادفی کوچک داخل همان Segment برای طبیعی‌تر شدن).
  6. تا پایان انیمیشن (transitionend یا تایمر معادل)، دکمه غیرفعال می‌ماند.
  7. نمایش Modal نتیجه (ResultModal.vue) بعد از پایان انیمیشن، نه هم‌زمان با دریافت پاسخ API.
  8. بعد از بستن 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 پیشنهادی

  1. pnpm install در Root (Workspace).
  2. pnpm --filter api prisma migrate deploy (اجرای Migrationها روی دیتابیس Production).
  3. Build سه اپلیکیشن (pnpm --filter api build, --filter admin build, --filter client build).
  4. Serve admin/client به‌صورت استاتیک از پشت Nginx (یا هر CDN).
  5. اجرای api با PM2/Docker با حداقل ۲ Instance پشت Load Balancer برای Zero-downtime Deploy (با توجه به این‌که Session در JWT است، نیازی به Sticky Session نیست).
  6. اجرای 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 قابل مهاجرت، ۵ طرح گردونه انتخابی).