# داکیومنت فنی پیاده‌سازی سامانه گردونه شانس و قرعه‌کشی **نسخه سند:** 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 + ` ``` ### ۱۷.۲ رفتار 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 قابل مهاجرت، ۵ طرح گردونه انتخابی).