Files

156 lines
11 KiB
Markdown
Raw Permalink Normal View History

# طرح — صفحه‌بندی واقعی کاتالوگ (۱۶ هزار رکورد)
تاریخ: ۱۴۰۵/۰۷/۲۰ (2026-10-12) — **طرح؛ منتظر تأیید**
> **مالک:** «دیتاها در اپ واقعی تا ۱۶ هزار رکورد می‌رود و گرفتن همه یک‌جا اشتباه
> است، حتماً باید پیجینیشن داشته باشد.»
موافقم. با این عدد، وضعیت فعلی قابل دفاع نیست. این سند می‌گوید دقیقاً چه چیزی
شکسته است، چرا کار از «اضافه کردن `take`/`skip`» بزرگ‌تر است، و با چه ترتیبی
بی‌ریسک انجام می‌شود.
---
## ۱. وضعیت امروز (بررسی‌شده در کد، نه حدس)
| لایه | واقعیت | مدرک |
|---|---|---|
| مسیر API | `GET /catalog/products` **هیچ پارامتری نمی‌پذیرد** | `catalog.controller.ts:10-12` |
| کوئری | یک `findMany` بدون `take`/`skip`، همراه `variants`+`images`+`category`+`brand`+`collections` | `catalog.service.ts:19-42` |
| کش سرور | کل خروجی، ۳۰ ثانیه در حافظه، با هر نوشتن باطل | `catalog-cache.service.ts` |
| «صفحه‌بندی» اپ | `PAGE_SIZE = 20` فقط یک **پنجره رندر** روی آرایه‌ی کامل است: `slice(0, visibleCount)` | `SearchView.vue:204-206`، `NewestView.vue:55-57` |
| در هر اسکرول | **صفر درخواست**، ۲۰ کارت بیشتر رندر می‌شود | همان |
**ایندکس‌ها — یافته‌ی مهم:** مدل `Product` **هیچ ایندکسی ندارد** (نه
`isActive`، نه `categoryId`، نه `brandId`، نه `createdAt`) و `ProductVariant`
ایندکسی روی `productId` ندارد. با ۱۶ هزار رکورد، صفحه‌بندی بدون ایندکس همان
کندی را می‌دهد؛ پس ایندکس **بخشی از این کار است**، نه یک بهبود جداگانه.
## ۲. چرا کار از «اضافه کردن take/skip» بزرگ‌تر است
هفت مصرف‌کننده روی فرض «کل کاتالوگ در حافظه است» نوشته شده‌اند. سه‌تا بحرانی‌اند
— هر سه کل لیست را می‌گیرند تا **یک چیز** پیدا کنند:
| جا | الان چه می‌کند | با ۱۶ هزار رکورد |
|---|---|---|
| `useProductDetail` | کل کاتالوگ را می‌گیرد تا **یک محصول** را `find` کند | بازکردن هر محصول = دانلود کل کاتالوگ |
| `cartStore.init` | کل کاتالوگ برای همگام‌سازی **۲-۳ قلم سبد** | هر بار باز شدن اپ |
| `configStore.init` | کل کاتالوگ فقط برای **لیست دسته‌ها** | صفحه خانه |
| `SearchView` | متن/دسته/برند/بازه وزن را روی کلاینت فیلتر می‌کند | فیلتر روی داده‌ی ناقص = نتیجه غلط |
| `Filters.vue` | گزینه‌های فیلتر را از کل لیست مشتق می‌کند | گزینه‌ها ناقص می‌شوند |
| `FavoritesView` | علاقه‌مندی‌ها را با `find` در کل لیست پیدا می‌کند | علاقه‌مندیِ خارج از صفحه دیده نمی‌شود |
| `useOrderItemProduct` | عنوان محصولِ آیتم سفارش را از کل لیست می‌خواند | سفارش قدیمی بی‌عنوان می‌شود |
**نتیجه:** اگر فقط `take`/`skip` به مسیر اضافه شود، جستجو و فیلتر **بی‌صدا غلط**
می‌شوند (روی ۲۰ رکورد اول فیلتر می‌کنند) و این بدتر از کندی است. پس هر
مصرف‌کننده باید مسیر مخصوص خودش را بگیرد.
## ۳. طراحی پیشنهادی
### (الف) چهار مسیر، به‌جای یک مسیر
| مسیر | برای چه | چرا جدا |
|---|---|---|
| `GET /catalog/products?page=&limit=&search=&categoryId=&brandId=&minGram=&maxGram=&sort=` | لیست و جستجو | **فیلتر و صفحه‌بندی سمت سرور**؛ تنها راه درست بودن نتیجه |
| `GET /catalog/products/:id` | صفحه محصول | یک رکورد، نه ۱۶ هزار |
| `GET /catalog/facets` | گزینه‌های فیلتر (دسته‌ها، برندها، بازه وزن) | کوچک، `distinct`/`aggregate`؛ جایگزین مشتق‌کردن از کل لیست |
| `POST /catalog/products/by-ids` | سبد، علاقه‌مندی، آیتم‌های سفارش | چند شناسه مشخص در یک درخواست |
شکل پاسخ لیست: `{ rows, total, page, limit }`. **عمداً** از `TableService`
استفاده نمی‌کنم: آن برای قرارداد XTable پنل ادمین است و شکل پاسخش
(`{rows, query:{...}}`) برای اپ بی‌مورد پیچیده است.
### (ب) سازگاری عقب‌رو — مهم
مسیر فعلی **نباید بشکند**: پنل ادمین، اسکریپت‌های smoke و نسخه‌های نصب‌شده‌ی اپ
از آن استفاده می‌کنند.
- بدون `page`/`limit` ⇒ **رفتار امروز** (کل لیست). پس هیچ مصرف‌کننده‌ای نمی‌شکند.
- با `page`/`limit` ⇒ پاسخ صفحه‌بندی‌شده.
- در لاگ، درخواست بدون `page` روی کاتالوگ بزرگ **هشدار** می‌دهد تا بفهمیم چه
کسی هنوز قدیمی است.
### (ج) کش سرور — بازطراحی
کش فعلی **یک** خروجی را نگه می‌دارد (`value: {at, data}`). با پارامترها این
نادرست می‌شود: صفحهٔ ۲ جواب صفحهٔ ۱ را می‌گیرد.
پیشنهاد: کلید کش = رشته‌ی پارامترهای نرمال‌شده، با سقف تعداد کلید (مثلاً ۵۰،
LRU) تا حافظه بی‌نهایت رشد نکند. باطل‌سازی موجود (middleware پریزما) **همه** را
با هم پاک می‌کند — که درست است، چون هر نوشتنی می‌تواند هر صفحه‌ای را عوض کند.
⚠️ سقف کلید لازم است: بدون آن، هر ترکیب فیلترِ کاربران یک ردیف کش می‌سازد و
حافظه‌ی سرور به‌تدریج پر می‌شود.
### (د) ایندکس‌ها (مهاجرت)
```
Product: @@index([isActive, createdAt]) ← لیست و مرتب‌سازی پیش‌فرض
@@index([categoryId])
@@index([brandId])
ProductVariant: @@index([productId]) ← الان نیست؛ هر include یک اسکن
ProductImage: @@index([productId])
```
جستجوی متنی: `contains` فارسی روی ۱۶ هزار ردیف با ایندکس معمولی سریع نمی‌شود.
پیشنهاد **فعلی**: همان `contains` با ایندکس بالا (برای ۱۶ هزار قابل قبول است).
اگر کند بود، مرحله‌ی بعد `pg_trgm` است — ولی **اول اندازه‌گیری، بعد اضافه کردن**.
### (ه) سمت اپ
| کار | تغییر |
|---|---|
| `catalogStore` | از «یک آرایه‌ی کامل» به «صفحات + `total`»؛ `load()` صفحه می‌گیرد، `loadMore()` صفحه بعد |
| `SearchView` / `NewestView` | `IntersectionObserver` به‌جای زیاد کردن `visibleCount`، **درخواست صفحه بعد** می‌زند؛ فیلتر/جستجو با debounce به سرور می‌رود و از صفحه ۱ شروع می‌کند |
| `Filters.vue` | گزینه‌ها از `/catalog/facets` |
| `useProductDetail` | `GET /catalog/products/:id` |
| `cartStore.init` | `by-ids` با شناسه‌های سبد |
| `FavoritesView` | `by-ids` با شناسه‌های علاقه‌مندی |
| `useOrderItemProduct` | `by-ids`، یا بهتر: عنوان از خود `OrderItem` snapshot (اگر دارد) |
| `configStore` | دسته‌ها از `/catalog/facets` |
**اندازه صفحه:** همان **۲۰** که الان پنجره‌ی رندر است، تا تجربه عوض نشود.
## ۴. ترتیب اجرا (هر مرحله قابل تست و بی‌شکست)
| مرحله | کار | وضعیت |
|---|---|---|
| ۱ | مهاجرت ایندکس‌ها | ✅ **انجام شد** (`cbb93de`) — `Product` هیچ ایندکسی نداشت |
| ۲ | مسیرهای سرور + تست | ✅ **انجام شد** (`cbb93de`) — صفحه‌بندی opt-in، فیلتر سمت سرور، ۱۷ تست |
| ۳ | صفحه محصول، سبد، علاقه‌مندی، دسته‌ها، برچسب آیتم سفارش | ✅ **انجام شد** — پنج مصرف‌کننده منتقل شد |
| ۴ | `SearchView` / `NewestView` / rails خانه → صفحه‌بندی واقعی | ⏳ **باقی** — پرریسک‌ترین (فیلتر + اسکرول) |
| ۵ | حذف خوانش کل کاتالوگ و هشدار لاگ | ⏳ فقط وقتی هیچ مصرف‌کننده‌ای نمانده |
### یادداشت مرحله ۳ (انجام‌شده)
«برچسب آیتم سفارش» در طرح اولیه نبود و سر راه پیدا شد: `OrderItem` فقط
`productVariantId` دارد نه `productId`، پس `?ids=` جوابش نمی‌داد. سرور همان را
با یک `include` برمی‌گرداند — که برای محصول حذف‌شده هم کار می‌کند، برعکس روش
قبلی که ردیف را خالی می‌گذاشت.
یک باگ هم در حین کار ساخته و گرفته شد: انتقال دسته‌ها به `/facets` باعث شد
`memory.init()` دیگر کاتالوگ را بار نزند و rails خانه خالی شود؛ `HomeView` حالا
صریحاً کاتالوگ را بار می‌کند.
**مرحله ۴ هنوز باقی است** — یعنی rails خانه، جستجو و «جدیدترین» همچنان کل
کاتالوگ را می‌گیرند. پس تا آن مرحله، سود واقعی ۱۶ هزار رکوردی کامل نشده.
## ۵. دو چیز که باید بدانی
**۱. مرجع صفحه‌بندی سرور داشت.** `ApiMobileController` برای لیست محصولات
صفحه‌بندی می‌فرستاد؛ پورت فعلی آن را به کلاینت منتقل کرده. پس این کار
**بازگشت به رفتار مرجع** است، نه قابلیت جدید — در ماتریس به‌عنوان STUB-03 ثبت
است.
**۲. ریسک واقعی این کار، جستجو و فیلتر است.** امروز روی کلاینت فوری و دقیق
کار می‌کنند. منتقل‌شدن به سرور یعنی: debounce، حالت بارگذاری، و «نتیجه‌ای نیست»
که باید با صفحه‌ی خالی اشتباه نشود. مرحله ۴ را عمداً آخر گذاشتم و پیشنهادم این
است که وقتی رسیدیم، **یک بار با هم در مرورگر امتحانش کنیم** — چون این بخش
تنها جایی است که تست واحد کفایت نمی‌کند.
---
**برای تصمیم تو:** مرحله ۱ و ۲ کاملاً بی‌ریسک‌اند و هیچ‌چیز را نمی‌شکنند. اگر
بگویی «برو»، از آن‌ها شروع می‌کنم و بعد از هر مرحله گزارش می‌دهم. اگر عجله داری،
مرحله ۳ همان‌جایی است که کاربر فوراً تفاوت را حس می‌کند (باز شدن صفحه محصول).