1579 lines
76 KiB
Markdown
1579 lines
76 KiB
Markdown
# داکیومنت فنی پیادهسازی سامانه گردونه شانس و قرعهکشی
|
||||
|
|
|
|||
|
|
**نسخه سند:** 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)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## فهرست مطالب
|
|||
|
|
|
|||
|
|
0. مقدمه
|
|||
|
|
1. Tech Stack نهایی
|
|||
|
|
2. معماری کلی و ساختار Monorepo
|
|||
|
|
3. مدل داده — Prisma Schema کامل
|
|||
|
|
4. ساختار پروژه Backend (NestJS)
|
|||
|
|
5. ماژول Authentication (OTP)
|
|||
|
|
6. ماژول Campaign
|
|||
|
|
7. ماژول Prize
|
|||
|
|
8. ماژول Chance و Chance Ledger
|
|||
|
|
9. Spin Engine — الگوریتم اجرایی و کنترل Concurrency
|
|||
|
|
10. Idempotency
|
|||
|
|
11. ماژول SMS (raygansms.com) + صف BullMQ
|
|||
|
|
12. ماژول Winner / User Prizes / کد جایزه
|
|||
|
|
13. ماژول File Storage (Local، قابل تعویض به S3)
|
|||
|
|
14. مستندات کامل API — کاربر
|
|||
|
|
15. مستندات کامل API — ادمین
|
|||
|
|
16. ساختار پروژه Admin Panel (Vue 3 + Ant Design Vue + Pinia)
|
|||
|
|
17. ساختار پروژه User Client (Vue 3 + Wheel Component)
|
|||
|
|
18. پنج طرح گردونه (Wheel Designs) قابل انتخاب توسط ادمین
|
|||
|
|
19. Security و Anti-Fraud
|
|||
|
|
20. Rate Limiting
|
|||
|
|
21. Environment Variables
|
|||
|
|
22. Docker / Deployment
|
|||
|
|
23. استراتژی تست و Mapping با Acceptance Criteria
|
|||
|
|
24. جمعبندی و مسیر توسعه آینده
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 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
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
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 جلوگیری شود.
|
|||
|
|
|
|||
|
|
### دیاگرام معماری
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
┌────────────────────┐ ┌──────────────────────┐
|
|||
|
|
│ 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`:
|
|||
|
|
|
|||
|
|
```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)
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
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 کاربر
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"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`)
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
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).
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
// 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 باشد.
|
|||
|
|
|
|||
|
|
### ۹.۱ ورودی و خروجی
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
// 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)
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
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.
|
|||
|
|
|
|||
|
|
### ۹.۵ تولید کد یکتای جایزه
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
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 خوانده میشود:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
@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)
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
@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 پیشفرض */ }
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
@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` و گذار مجاز بین آنها
|
|||
|
|
|
|||
|
|
برای جوایز فیزیکی:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
NEW → CONTACTED → DELIVERED
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
برای کدهای تخفیف/اعتبار:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
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، معماریپذیر طبق بخش ۲۱ سند اصلی)
|
|||
|
|
|
|||
|
|
جدول اضافه پیشنهادی برای فاز بعد (فعلاً پیادهسازی نمیشود ولی مدل داده اجازه میدهد):
|
|||
|
|
|
|||
|
|
```prisma
|
|||
|
|
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 باشد:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
export interface StorageProviderInterface {
|
|||
|
|
upload(file: Buffer, options: { folder: string; filename: string; mimeType: string }): Promise<{ url: string; path: string }>;
|
|||
|
|
delete(path: string): Promise<void>;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
@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 استفاده میکنند:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"data": [ /* items */ ],
|
|||
|
|
"meta": { "page": 1, "pageSize": 20, "total": 134, "totalPages": 7 }
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 16. ساختار پروژه Admin Panel (Vue 3 + Ant Design Vue + Pinia)
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
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)
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
// 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)
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
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 میکند:
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<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
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
// 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 کند:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
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` بهصورت چندلایه:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
```env
|
|||
|
|
# 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
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
# 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 قابل مهاجرت، ۵ طرح گردونه انتخابی).
|